475 lines
14 KiB
JavaScript
475 lines
14 KiB
JavaScript
|
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;
|