const { set: lodashSet, get: lodashGet, chunk: lodashChunk } = require("@11ty/lodash-custom"); const { isPlainObject } = require("@11ty/eleventy-utils"); const EleventyBaseError = require("../EleventyBaseError"); const { DeepCopy } = require("../Util/Merge"); const { ProxyWrap } = require("../Util/ProxyWrap"); let serverlessUrlFilter = require("../Filters/ServerlessUrl"); class PaginationConfigError extends EleventyBaseError {} class PaginationError extends EleventyBaseError {} class Pagination { constructor(tmpl, data, config) { if (!config) { throw new PaginationConfigError("Expected `config` argument to Pagination class."); } this.config = config; this.setTemplate(tmpl); this.setData(data); } get inputPathForErrorMessages() { if (this.template) { return ` (${this.template.inputPath})`; } return ""; } static hasPagination(data) { return "pagination" in data; } hasPagination() { if (!this.data) { throw new Error( `Missing \`setData\` call for Pagination object${this.inputPathForErrorMessages}` ); } return Pagination.hasPagination(this.data); } circularReferenceCheck(data) { if (data.eleventyExcludeFromCollections) { return; } let key = data.pagination.data; let tags = data.tags || []; for (let tag of tags) { if (`collections.${tag}` === key) { throw new PaginationError( `Pagination circular reference${this.inputPathForErrorMessages}, data:\`${key}\` iterates over both the \`${tag}\` tag and also supplies pages to that tag.` ); } } } setData(data) { this.data = data || {}; this.target = []; if (!this.hasPagination()) { return; } if (!data.pagination) { throw new Error( `Misconfigured pagination data in template front matter${this.inputPathForErrorMessages} (YAML front matter precaution: did you use tabs and not spaces for indentation?).` ); } else if (!("size" in data.pagination)) { throw new Error( `Missing pagination size in front matter data${this.inputPathForErrorMessages}` ); } this.circularReferenceCheck(data); this.size = data.pagination.size; this.alias = data.pagination.alias; // TODO do we need the full data set for serverless? this.fullDataSet = this._get(this.data, this._getDataKey()); // this returns an array this.target = this._resolveItems(); // truncate pagination data if user-supplied `serverlessFilter` function if ( data.pagination.serverless && this._has(data, data.pagination.serverless) && typeof data.pagination.serverlessFilter === "function" ) { // Warn: this doesn’t run filter/before/pagination transformations // Warn: `pagination.pages`, pageNumber, links, hrefs, etc let serverlessPaginationKey = this._get(data, data.pagination.serverless); this.chunkedItems = [ data.pagination.serverlessFilter(this.fullDataSet, serverlessPaginationKey), ]; } else { this.chunkedItems = this.pagedItems; } } setTemplate(tmpl) { this.template = tmpl; } _getDataKey() { return this.data.pagination.data; } shouldResolveDataToObjectValues() { if ("resolve" in this.data.pagination) { return this.data.pagination.resolve === "values"; } return false; } isFiltered(value) { if ("filter" in this.data.pagination) { let filtered = this.data.pagination.filter; if (Array.isArray(filtered)) { return filtered.indexOf(value) > -1; } return filtered === value; } return false; } _has(target, key) { let notFoundValue = "__NOT_FOUND_ERROR__"; let data = lodashGet(target, key, notFoundValue); return data !== notFoundValue; } _get(target, key) { let notFoundValue = "__NOT_FOUND_ERROR__"; let data = lodashGet(target, key, notFoundValue); if (data === notFoundValue) { throw new Error( `Could not find pagination data${this.inputPathForErrorMessages}, went looking for: ${key}` ); } return data; } _resolveItems() { let keys; if (Array.isArray(this.fullDataSet)) { keys = this.fullDataSet; this.paginationTargetType = "array"; } else if (isPlainObject(this.fullDataSet)) { this.paginationTargetType = "object"; if (this.shouldResolveDataToObjectValues()) { keys = Object.values(this.fullDataSet); } else { keys = Object.keys(this.fullDataSet); } } else { throw new Error( `Unexpected data found in pagination target${this.inputPathForErrorMessages}: expected an Array or an Object.` ); } // keys must be an array let result = keys.slice(); if (this.data.pagination.before && typeof this.data.pagination.before === "function") { // we don’t need to make a copy of this because we .slice() above to create a new copy let fns = {}; if (this.config) { fns = this.config.javascriptFunctions; } result = this.data.pagination.before.call(fns, result, this.data); } if (this.data.pagination.reverse === true) { result = result.reverse(); } if (this.data.pagination.filter) { result = result.filter((value) => !this.isFiltered(value)); } return result; } get pagedItems() { if (!this.data) { throw new Error( `Missing \`setData\` call for Pagination object${this.inputPathForErrorMessages}` ); } const chunks = lodashChunk(this.target, this.size); if (this.data.pagination && this.data.pagination.generatePageOnEmptyData) { return chunks.length ? chunks : [[]]; } else { return chunks; } } getPageCount() { if (!this.hasPagination()) { return 0; } return this.chunkedItems.length; } getNormalizedItems(pageItems) { return this.size === 1 ? pageItems[0] : pageItems; } getOverrideDataPages(items, pageNumber) { return { // See Issue #345 for more examples page: { previous: pageNumber > 0 ? this.getNormalizedItems(items[pageNumber - 1]) : null, next: pageNumber < items.length - 1 ? this.getNormalizedItems(items[pageNumber + 1]) : null, first: items.length ? this.getNormalizedItems(items[0]) : null, last: items.length ? this.getNormalizedItems(items[items.length - 1]) : null, }, pageNumber, }; } getOverrideDataLinks(pageNumber, templateCount, links) { let obj = {}; // links are okay but hrefs are better obj.previousPageLink = pageNumber > 0 ? links[pageNumber - 1] : null; obj.previous = obj.previousPageLink; obj.nextPageLink = pageNumber < templateCount - 1 ? links[pageNumber + 1] : null; obj.next = obj.nextPageLink; obj.firstPageLink = links.length > 0 ? links[0] : null; obj.lastPageLink = links.length > 0 ? links[links.length - 1] : null; obj.links = links; // todo deprecated, consistency with collections and use links instead obj.pageLinks = links; return obj; } getOverrideDataHrefs(pageNumber, templateCount, hrefs) { let obj = {}; // hrefs are better than links obj.previousPageHref = pageNumber > 0 ? hrefs[pageNumber - 1] : null; obj.nextPageHref = pageNumber < templateCount - 1 ? hrefs[pageNumber + 1] : null; obj.firstPageHref = hrefs.length > 0 ? hrefs[0] : null; obj.lastPageHref = hrefs.length > 0 ? hrefs[hrefs.length - 1] : null; obj.hrefs = hrefs; // better names obj.href = { previous: obj.previousPageHref, next: obj.nextPageHref, first: obj.firstPageHref, last: obj.lastPageHref, }; return obj; } async getPageTemplates() { if (!this.data) { throw new Error( `Missing \`setData\` call for Pagination object${this.inputPathForErrorMessages}` ); } if (!this.hasPagination()) { return []; } let entries = []; let items = this.chunkedItems; let pages = this.size === 1 ? items.map((entry) => entry[0]) : items; let links = []; let hrefs = []; let hasPermalinkField = Boolean(this.data[this.config.keys.permalink]); let hasComputedPermalinkField = Boolean( this.data.eleventyComputed && this.data.eleventyComputed[this.config.keys.permalink] ); // Do *not* pass collections through DeepCopy, we’ll re-add them back in later. let collections = this.data.collections; if (collections) { delete this.data.collections; } let parentData = DeepCopy( { pagination: { data: this.data.pagination.data, size: this.data.pagination.size, alias: this.alias, pages, }, }, this.data ); // Restore skipped collections if (collections) { this.data.collections = collections; // Keep the original reference to the collections, no deep copy!! parentData.collections = collections; } // TODO future improvement dea: use a light Template wrapper for paged template clones (PagedTemplate?) // so that we don’t have the memory cost of the full template (and can reuse the parent // template for some things) let indeces = new Set(); let currentPageIndex; // Serverless pagination: if (this._has(this.data, "pagination.serverless")) { let serverlessPaginationKey; if (this.paginationTargetType === "object" && this.shouldResolveDataToObjectValues()) { serverlessPaginationKey = Object.keys(this.fullDataSet)[0]; } else { serverlessPaginationKey = 0; } if (this._has(this.data, this.data.pagination.serverless)) { serverlessPaginationKey = this._get(this.data, this.data.pagination.serverless); } if (this.paginationTargetType === "array") { currentPageIndex = parseInt(serverlessPaginationKey, 10); indeces.add(0); // first if (currentPageIndex > 0) { indeces.add(currentPageIndex - 1); // previous } if (currentPageIndex >= 0 && currentPageIndex <= items.length - 1) { indeces.add(currentPageIndex); // current } if (currentPageIndex + 1 < items.length) { indeces.add(currentPageIndex + 1); // next } indeces.add(items.length - 1); // last } else if (this.paginationTargetType === "object") { if (this.shouldResolveDataToObjectValues()) { currentPageIndex = Object.keys(this.fullDataSet).findIndex( (key) => key === serverlessPaginationKey ); } else { currentPageIndex = items.findIndex((entry) => entry[0] === serverlessPaginationKey); } // Array->findIndex returns -1 when not found if (currentPageIndex !== -1) { indeces.add(currentPageIndex); // current } } } else { for (let j = 0; j <= items.length - 1; j++) { indeces.add(j); } } for (let pageNumber of indeces) { let cloned = this.template.clone(); if (pageNumber > 0 && !hasPermalinkField && !hasComputedPermalinkField) { cloned.setExtraOutputSubdirectory(pageNumber); } let paginationData = { pagination: { items: items[pageNumber], }, page: {}, }; Object.assign(paginationData.pagination, this.getOverrideDataPages(items, pageNumber)); if (this.alias) { // When aliasing an object in serverless, use the object value and not the key if ( this.paginationTargetType === "object" && this._has(this.data, this.data.pagination.serverless) ) { // This should maybe be the default for all object pagination, not just serverless ones? let keys = this.getNormalizedItems(items[pageNumber]); if (Array.isArray(keys)) { lodashSet( paginationData, this.alias, key.map((key) => this._get(this.fullDataSet, key)) ); } else { if (this.shouldResolveDataToObjectValues()) { lodashSet(paginationData, this.alias, keys); } else { lodashSet(paginationData, this.alias, this._get(this.fullDataSet, keys)); } } } else { lodashSet(paginationData, this.alias, this.getNormalizedItems(items[pageNumber])); } } // Do *not* deep merge pagination data! See https://github.com/11ty/eleventy/issues/147#issuecomment-440802454 let clonedData = ProxyWrap(paginationData, parentData); let { linkInstance, rawPath, path, href } = await cloned.getOutputLocations(clonedData); // TODO subdirectory to links if the site doesn’t live at / if (rawPath) { links.push("/" + rawPath); } if (this._has(this.data, "pagination.serverless")) { let keys = this.data.pagination.serverless.split("."); let key = keys.pop(); let serverlessUrls = linkInstance.getServerlessUrls(); let validUrls = Object.values(serverlessUrls) .flat() .filter((entry) => entry.includes(`/:${key}/`)); if (validUrls.length === 0) { throw new Error( `Serverless pagination template (${this.data.page.inputPath}) has no \`permalink.${key}\` with \`/:${key}/\`` ); } href = serverlessUrlFilter(validUrls[0], { [key]: pageNumber }); } hrefs.push(href); // page.url and page.outputPath are used to avoid another getOutputLocations call later, see Template->addComputedData clonedData.page.url = href; clonedData.page.outputPath = path; entries.push({ pageNumber, template: cloned, data: clonedData, }); } // we loop twice to pass in the appropriate prev/next links (already full generated now) let index = 0; for (let pageEntry of entries) { let linksObj = this.getOverrideDataLinks(index, items.length, links); Object.assign(pageEntry.data.pagination, linksObj); let hrefsObj = this.getOverrideDataHrefs(index, items.length, hrefs); Object.assign(pageEntry.data.pagination, hrefsObj); index++; } // Final output is filtered for serverless return entries.filter((entry) => { return !currentPageIndex || entry.pageNumber === currentPageIndex; }); } } module.exports = Pagination;