diff --git a/next/src/components/core/Query/data.ts b/next/src/components/core/Query/data.ts index d36528a..741adc2 100644 --- a/next/src/components/core/Query/data.ts +++ b/next/src/components/core/Query/data.ts @@ -109,16 +109,107 @@ const queryContentNodes = gql` } `; +const PAGINATION_BLOCKS = new Set([ + 'core/query-pagination-next', + 'core/query-pagination-previous', + 'core/query-pagination-numbers', +]); + +const buildPageHref = (baseUri: string, page: number) => { + if (page <= 1) return baseUri; + const base = baseUri.endsWith('/') ? baseUri : `${baseUri}/`; + return `${base}page/${page}/`; +}; + +/** + * Compute the request-time attributes for a single pagination child block, + * mirroring what WordPress core injects when it server-renders these blocks. + */ +const paginationChildAttrs = ( + name: string, + attrs: Record, + currentPage: number, + totalPages: number | null, + baseUri: string +): Record => { + if (name === 'core/query-pagination-numbers') { + return { currentPage, totalPages, baseUri }; + } + + const isNext = name === 'core/query-pagination-next'; + const rawLabel = typeof attrs.label === 'string' ? attrs.label.trim() : ''; + const label = rawLabel || (isNext ? 'Next Page' : 'Previous Page'); + + let href: string | null = null; + let enabled = false; + + if (isNext) { + const canGoNext = totalPages === null ? true : currentPage < totalPages; + if (canGoNext) { + href = buildPageHref(baseUri, currentPage + 1); + enabled = true; + } + } else if (currentPage > 1) { + href = buildPageHref(baseUri, currentPage - 1); + enabled = true; + } + + return { href, label, isDisabled: !enabled }; +}; + +/** + * Walk this query's `innerBlocks` and inject the resolved pagination state into + * its pagination children, so they stay in sync at request time (no rebuild + * needed when posts are added). Nested `core/query` loops are left untouched — + * each resolves its own pagination. + */ +const injectPagination = ( + blocks: BlockPropsType[], + currentPage: number, + totalPages: number | null, + baseUri: string +): BlockPropsType[] => + blocks.map((block) => { + if (block.name === 'core/query') return block; + + if (PAGINATION_BLOCKS.has(block.name)) { + return { + ...block, + attributes: { + ...block.attributes, + ...paginationChildAttrs( + block.name, + block.attributes, + currentPage, + totalPages, + baseUri + ), + }, + }; + } + + return { + ...block, + innerBlocks: injectPagination( + block.innerBlocks ?? [], + currentPage, + totalPages, + baseUri + ), + }; + }); + export const getData = async ( fetcher: FetchApiFuncType, attrs: QueryAttributes | null = null, - lang: string | null = null + lang: string | null = null, + context: BlockDataContext = {} ) => { const perPageRaw = attrs?.query?.perPage; const perPage = Math.min(100, Math.max(1, toPositiveInt(perPageRaw) ?? 10)); - const offsetRaw = attrs?.query?.offset; - const offset = Math.max(0, toPositiveInt(offsetRaw) ?? 0); + const page = context?.page && context.page > 0 ? context.page : 1; + const offset = (page - 1) * perPage; const order = toOrderEnum(attrs?.query?.order); const orderby = toOrderByEnum(attrs?.query?.orderBy); @@ -157,7 +248,7 @@ export const getData = async ( typeof total === 'number' && total >= 0 ? Math.ceil(total / perPage) : null; - const currentPage = Math.floor(offset / perPage) + 1; + const currentPage = page; const nodes = (data?.contentNodes?.nodes ?? []).map((node: unknown) => ({ excerpt: '', @@ -167,6 +258,16 @@ export const getData = async ( ...(node as Record), })); + const baseUri = context?.baseUri ?? '/'; + const innerBlocks = Array.isArray(context?.innerBlocks) + ? injectPagination( + context.innerBlocks, + currentPage, + totalPages, + baseUri + ) + : undefined; + return { data: { posts: { @@ -180,5 +281,6 @@ export const getData = async ( total, totalPages, }, + ...(innerBlocks !== undefined ? { innerBlocks } : {}), }; }; diff --git a/next/src/components/core/QueryPaginationNumbers/index.tsx b/next/src/components/core/QueryPaginationNumbers/index.tsx new file mode 100644 index 0000000..f7928bd --- /dev/null +++ b/next/src/components/core/QueryPaginationNumbers/index.tsx @@ -0,0 +1,72 @@ +import Link from 'next/link'; + +/** + * Mirrors WordPress `paginate_links()` `end_size` (how many pages are always + * shown at the very start and end of the list). + */ +const END_SIZE = 1; + +const buildHref = (baseUri: string, page: number) => { + if (page <= 1) return baseUri; + const base = baseUri.endsWith('/') ? baseUri : `${baseUri}/`; + return `${base}page/${page}/`; +}; + +/** + * Numbered pagination for a `core/query` loop. `currentPage`, `totalPages` and + * `baseUri` are injected at request time by the parent query's data layer (see + * `core/Query/data.ts`), matching the next/previous blocks. Rendering replicates + * `paginate_links()` with `prev_next` disabled: + * the first/last `END_SIZE` pages plus a `midSize` window around the current + * page are shown, and skipped ranges collapse into `…` dots. + */ +export default function QueryPaginationNumbers({ + midSize = 2, + currentPage, + totalPages, + baseUri = '/', +}: QueryPaginationNumbersProps) { + if (!currentPage || !totalPages || totalPages < 2) return null; + + const elements: React.ReactNode[] = []; + let dots = false; + + for (let page = 1; page <= totalPages; page++) { + if (page === currentPage) { + elements.push( + + {page} + + ); + dots = true; + } else if ( + page <= END_SIZE || + (page >= currentPage - midSize && page <= currentPage + midSize) || + page > totalPages - END_SIZE + ) { + elements.push( + + {page} + + ); + dots = true; + } else if (dots) { + elements.push( + + … + + ); + dots = false; + } + } + + return
{elements}
; +} diff --git a/next/src/components/core/QueryPaginationNumbers/typings.d.ts b/next/src/components/core/QueryPaginationNumbers/typings.d.ts new file mode 100644 index 0000000..9834535 --- /dev/null +++ b/next/src/components/core/QueryPaginationNumbers/typings.d.ts @@ -0,0 +1,12 @@ +interface QueryPaginationNumbersAttributes extends BlockAttributes { + midSize?: number; +} + +interface QueryPaginationNumbersProps extends QueryPaginationNumbersAttributes { + /** Injected at request time by the parent `core/query` data layer. */ + currentPage?: number; + /** Injected by the parent query; `null`/undefined when the total is unknown. */ + totalPages?: number | null; + /** Base uri of the paginated node (e.g. `/blog/`), injected by the parent query. */ + baseUri?: string; +} diff --git a/next/src/components/global/Blocks.tsx b/next/src/components/global/Blocks.tsx index eb6906d..b8423e7 100644 --- a/next/src/components/global/Blocks.tsx +++ b/next/src/components/global/Blocks.tsx @@ -50,6 +50,8 @@ const blocksList: Record Promise> = { 'core/query': () => import('../core/Query'), 'core/query-pagination': () => import('../core/QueryPagination'), 'core/query-pagination-next': () => import('../core/QueryPaginationNext'), + 'core/query-pagination-numbers': () => + import('../core/QueryPaginationNumbers'), 'core/query-pagination-previous': () => import('../core/QueryPaginationPrevious'), 'core/query-title': () => import('../core/QueryTitle'), diff --git a/next/src/lib/format-blocks-json.ts b/next/src/lib/format-blocks-json.ts index 4cacf01..bf1810e 100644 --- a/next/src/lib/format-blocks-json.ts +++ b/next/src/lib/format-blocks-json.ts @@ -4,7 +4,12 @@ import getBlockFinalComponentProps from '@/lib/get-block-final-component-props'; export default async function formatBlocksJSON( blocksJSON: string, - options?: { skipGetData?: boolean; lang?: string | null } + options?: { + skipGetData?: boolean; + lang?: string | null; + page?: number; + baseUri?: string; + } ) { /** * Replace ocurrences of WP upload URIs with relative url diff --git a/next/src/lib/get-block-final-component-props.ts b/next/src/lib/get-block-final-component-props.ts index 09c5b20..f07c107 100644 --- a/next/src/lib/get-block-final-component-props.ts +++ b/next/src/lib/get-block-final-component-props.ts @@ -38,7 +38,12 @@ export default function getBlockFinalComponentProps( attributes: object; innerBlocks: Array; }, - options?: { skipGetData?: boolean; lang?: string | null } + options?: { + skipGetData?: boolean; + lang?: string | null; + page?: number; + baseUri?: string; + } ): Promise { return new Promise(async (res) => { const props: BlockPropsType = { @@ -48,9 +53,9 @@ export default function getBlockFinalComponentProps( }; Promise.allSettled([ - getAttributes(name, attributes, options), + getAttributes(name, attributes, innerBlocks, options), getInnerBlocks(innerBlocks, options), - ]).then(([attrsResult, blksResult]) => { + ]).then(async ([attrsResult, blksResult]) => { if (attrsResult.status === 'fulfilled') { const { attrs, innerBlocks: dataInnerBlocks } = attrsResult.value as { @@ -59,10 +64,16 @@ export default function getBlockFinalComponentProps( }; props.attributes = attrs ?? {}; - // If getData returned innerBlocks, use those (fresh) and skip the static ones. - // (this is a fix made for core/navigation for example, which has links as innerBlocks, but we want them to be always up to date, even if it's part of the FSE template) + // If getData returned innerBlocks, use those (fresh) instead of the + // static ones — but still run them back through this same enrichment + // pipeline, so any dynamic block nested inside (e.g. a `core/navigation` + // nested inside a submenu, or a pagination block inside a `core/query`) + // gets its own getData resolved too. if (dataInnerBlocks !== undefined) { - props.innerBlocks = dataInnerBlocks; + props.innerBlocks = (await getInnerBlocks( + dataInnerBlocks, + options + ).catch(() => [])) as BlockPropsType['innerBlocks']; } else if (blksResult.status === 'fulfilled') { props.innerBlocks = (blksResult.value as BlockPropsType['innerBlocks']) ?? @@ -88,7 +99,13 @@ export default function getBlockFinalComponentProps( const getAttributes = ( name: string, attributes: object, - options?: { skipGetData?: boolean; lang?: string | null } + innerBlocks: Array, + options?: { + skipGetData?: boolean; + lang?: string | null; + page?: number; + baseUri?: string; + } ) => new Promise(async (res) => { if ( @@ -104,7 +121,8 @@ const getAttributes = ( getData?: ( fetcher: FetchApiFuncType, attrs: object, - lang?: string | null + lang?: string | null, + context?: BlockDataContext ) => Promise; } ).getData; @@ -114,7 +132,11 @@ const getAttributes = ( return; } - getData(fetchAPI, attributes, options?.lang ?? null).then( + getData(fetchAPI, attributes, options?.lang ?? null, { + page: options?.page, + baseUri: options?.baseUri, + innerBlocks, + }).then( (data = {}) => { const { innerBlocks, ...restData } = data as { innerBlocks?: BlockPropsType[]; @@ -130,7 +152,12 @@ const getAttributes = ( const getInnerBlocks = ( blocks: Array, - options?: { skipGetData?: boolean; lang?: string | null } + options?: { + skipGetData?: boolean; + lang?: string | null; + page?: number; + baseUri?: string; + } ) => new Promise((res, rej) => { if (!(blocks?.length > 0)) rej([]); diff --git a/next/src/lib/get-node-by-uri.ts b/next/src/lib/get-node-by-uri.ts index 37b501a..d903afd 100644 --- a/next/src/lib/get-node-by-uri.ts +++ b/next/src/lib/get-node-by-uri.ts @@ -60,9 +60,6 @@ export default async function getNodeByURI( const response = await fetchAPI(query, { variables, auth, - headers: { - 'X-Query-Page': String(routePage && routePage > 0 ? routePage : 1), - }, }); const { node: rawNode, seo, generalSettings } = response; @@ -119,12 +116,14 @@ export default async function getNodeByURI( previewDraft ? (node.preview?.node?.blocksJSON ?? '') : (node?.blocksJSON ?? ''), - { lang } + { lang, page: routePage, baseUri: uri } ), getTemplateData(node), enrichTemplateBlocks( getTemplateBlocks(node?.fseTemplate?.slug), - lang + lang, + routePage, + uri ), ]) .then(([bProm, tProm, tbProm]) => ({ @@ -339,13 +338,15 @@ const getTemplateBlocks = (templateSlug: string): BlockPropsType[] => { */ const enrichTemplateBlocks = ( blocks: BlockPropsType[], - lang: string | null = null + lang: string | null = null, + page = 1, + baseUri: string | undefined = undefined ): Promise => blocks.length === 0 ? Promise.resolve([]) : Promise.allSettled( blocks.map((block) => - getBlockFinalComponentProps(block, { lang }) + getBlockFinalComponentProps(block, { lang, page, baseUri }) ) ).then( (results) => diff --git a/next/src/typings.d.ts b/next/src/typings.d.ts index b9301cd..dfba9ec 100644 --- a/next/src/typings.d.ts +++ b/next/src/typings.d.ts @@ -25,6 +25,20 @@ type BlockPropsType = { innerBlocks: Array; }; +/** + * Request-time context threaded into a block's `getData(fetcher, attrs, lang, context)`. + * Populated per request from the resolved node (see get-node-by-uri.ts) so blocks + * like `core/query` can resolve pagination without a rebuild. + */ +type BlockDataContext = { + /** Current query-loop page from the `/page/{n}` route (1-based). */ + page?: number; + /** Base uri of the resolved node (e.g. "/blog/"), used to build page links. */ + baseUri?: string; + /** The block's own `innerBlocks`, so `getData` can enrich/override children. */ + innerBlocks?: Array; +}; + type FseTemplateEntry = { slug: string; blocks: Array; diff --git a/wordpress/theme/includes/_loader.php b/wordpress/theme/includes/_loader.php index 37ec133..a14a58f 100644 --- a/wordpress/theme/includes/_loader.php +++ b/wordpress/theme/includes/_loader.php @@ -56,7 +56,6 @@ require_once __DIR__ . '/graphql/navigation-inner-blocks.php'; require_once __DIR__ . '/graphql/node-idtype.php'; require_once __DIR__ . '/graphql/post-edit-link.php'; -require_once __DIR__ . '/graphql/query-pagination-offset.php'; require_once __DIR__ . '/graphql/register-content-type-translations.php'; require_once __DIR__ . '/graphql/register-fse-templates.php'; require_once __DIR__ . '/graphql/register-logo.php'; diff --git a/wordpress/theme/includes/graphql/query-pagination-offset.php b/wordpress/theme/includes/graphql/query-pagination-offset.php deleted file mode 100644 index 90bfe71..0000000 --- a/wordpress/theme/includes/graphql/query-pagination-offset.php +++ /dev/null @@ -1,168 +0,0 @@ - 0 ? $page_header : 1; - - $blocks = json_decode($result, true); - if (! is_array($blocks)) { - return $result; - } - - $base_uri = get_source_uri($source); - $blocks = rewrite_blocks_deep($blocks, $page, $base_uri, null); - return wp_json_encode($blocks); -} - -/** - * Recursively rewrite core/query blocks inside a parsed blocks tree. - * - * @param array $blocks Parsed blocksJSON array. - * @param int $page Current page number (>= 1). - * @param string|null $base_uri Source node uri (e.g. "/blog/"). - * @param int|null $max_pages Optional max pages from surrounding core/query attributes. - * - * @return array - */ -function rewrite_blocks_deep(array $blocks, int $page, ?string $base_uri, ?int $max_pages): array { - foreach ($blocks as $i => $block) { - if (! is_array($block)) { - continue; - } - - $name = $block['name'] ?? $block['blockName'] ?? ''; - $attributes = $block['attributes'] ?? $block['attrs'] ?? array(); - - if ('core/query' === $name && is_array($attributes)) { - $attributes['query'] = isset($attributes['query']) && is_array($attributes['query']) - ? $attributes['query'] - : array(); - - $per_page = isset($attributes['query']['perPage']) ? (int) $attributes['query']['perPage'] : 0; - $per_page = $per_page > 0 ? $per_page : 10; - - $attributes['query']['offset'] = ($page - 1) * $per_page; - - // Best-effort max pages (Gutenberg stores it under query.pages). - $max_pages = isset($attributes['query']['pages']) ? (int) $attributes['query']['pages'] : $max_pages; - $max_pages = $max_pages && $max_pages > 0 ? $max_pages : null; - - if (array_key_exists('attributes', $block)) { - $block['attributes'] = $attributes; - } else { - $block['attrs'] = $attributes; - } - $blocks[$i] = $block; - } - - if ('core/query-pagination-next' === $name || 'core/query-pagination-previous' === $name) { - $blocks[$i] = rewrite_pagination_block($block, $page, $base_uri, $max_pages); - } - - if (! empty($block['innerBlocks']) && is_array($block['innerBlocks'])) { - $block['innerBlocks'] = rewrite_blocks_deep($block['innerBlocks'], $page, $base_uri, $max_pages); - $blocks[$i] = $block; - } - } - - return $blocks; -} - -/** - * Get source uri from WPGraphQL source object if present. - * - * @param mixed $source GraphQL source. - * - * @return string|null - */ -function get_source_uri($source): ?string { - if (is_object($source)) { - if (isset($source->uri) && is_string($source->uri) && '' !== $source->uri) { - return $source->uri; - } - if (method_exists($source, 'get_uri')) { - $uri = $source->get_uri(); - return is_string($uri) && '' !== $uri ? $uri : null; - } - } - return null; -} - -/** - * Inject href/label/isDisabled into pagination blocks so the frontend doesn't need context threading. - * - * @param array $block Parsed block. - * @param int $page Current page number. - * @param string|null $base_uri Base uri (e.g. "/blog/"). - * @param int|null $max_pages Optional max pages. - * - * @return array - */ -function rewrite_pagination_block(array $block, int $page, ?string $base_uri, ?int $max_pages): array { - $name = $block['name'] ?? $block['blockName'] ?? ''; - - $attrs_key = array_key_exists('attributes', $block) ? 'attributes' : 'attrs'; - $attributes = isset($block[$attrs_key]) && is_array($block[$attrs_key]) ? $block[$attrs_key] : array(); - - $base_uri = is_string($base_uri) && '' !== $base_uri ? $base_uri : '/'; - $base_uri = '/' . ltrim($base_uri, '/'); - - $default_label = ('core/query-pagination-next' === $name) ? 'Next Page' : 'Previous Page'; - $label = isset($attributes['label']) && is_string($attributes['label']) && '' !== trim($attributes['label']) - ? trim($attributes['label']) - : $default_label; - - $href = null; - $is_enabled = false; - - if ('core/query-pagination-previous' === $name) { - if ($page > 1) { - $target = $page - 1; - $href = (1 === $target) ? $base_uri : trailingslashit($base_uri) . 'page/' . $target . '/'; - $is_enabled = true; - } - } elseif ('core/query-pagination-next' === $name) { - $can_go_next = (null === $max_pages) ? true : ($page < $max_pages); - if ($can_go_next) { - $target = $page + 1; - $href = trailingslashit($base_uri) . 'page/' . $target . '/'; - $is_enabled = true; - } - } - - $attributes['href'] = $href; - $attributes['label'] = $label; - $attributes['isDisabled'] = ! $is_enabled; - - $block[$attrs_key] = $attributes; - return $block; -}