devsite/node_modules/@11ty/eleventy/src/Plugins/Pagination.js
2024-07-07 18:49:38 -07:00

475 lines
14 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 doesnt 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 dont 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, well 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 dont 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 doesnt 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;