eleventy-plugin-filter-page 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,7 @@
1
+ Copyright (c) 2026 Daniel Maiovskyi
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,161 @@
1
+ # eleventy-plugin-filter-page
2
+ This Eleventy plugin repository contains two key pieces for "Eleventy Collection Filter" functionality:
3
+ - ecf.plugin.js
4
+ - ecf.filter.js
5
+
6
+ ## Usage
7
+ ### 1. Install the plugin
8
+ ```sh
9
+ npm install eleventy-plugin-filter-page
10
+ ```
11
+
12
+ ### 2. Add this plugin to your config
13
+ ```js
14
+ eleventyConfig.addPlugin(ecfPlugin);
15
+ ```
16
+
17
+ ### 3. Configure the filters
18
+ There are multiple ways to provide configuration to the plugin, but the configuration itself follows the same schema.
19
+
20
+ Code for a *complete* 11ty project with an example config and resulting page can be found in the [example folder](./example/). You can preview the example website here: link eventually.
21
+
22
+ #### Configuration schema
23
+ ```ts
24
+ interface PluginConfig {
25
+ collection: string; // Name of the Eleventy collection this config applies to
26
+ filters: Array< // List of filters available for this collection
27
+ {
28
+ type: "radio";
29
+ label: string; // Human-readable label shown in the UI
30
+ field: string; // Front-matter field this filter targets
31
+ } | {
32
+ type: "checkbox";
33
+ label: string;
34
+ field: string;
35
+ } | {
36
+ type: "text";
37
+ label: string;
38
+ field: string;
39
+ /** Optional external <form> ID for wiring into existing markup. See TODO: Step 5 */
40
+ form?: string;
41
+ options?: {
42
+ placeholder?: string; // Placeholder text shown in the input
43
+ };
44
+ }
45
+ >;
46
+ }
47
+ ```
48
+
49
+ #### 3.1 Config as plugin opts
50
+ ```js
51
+ eleventyConfig.addPlugin(ecfPlugin, {
52
+ config: {
53
+ collection: "posts",
54
+ filters: [ /* ... */ ]
55
+ }
56
+ });
57
+ ```
58
+ #### 3.2 Separate file
59
+ In case you want to expose your configuration file to a CMS (ex. DecapCMS), you may want to store the configuration in a separate `.json` file and import it as follows:
60
+ ```js
61
+ eleventyConfig.addPlugin(ecfPlugin, {
62
+ config: require('./src/_data/ecfConfig.json')
63
+ });
64
+ ```
65
+
66
+ A sample of DecapCMS `config.yml` for this purpose can be seen here: [config.yml](./example/src/admin/config.yml)
67
+
68
+ #### 3.3 _data/ecfConfig.json
69
+ Eleventy exposes files in the `_data` folder as front-matter metadata to the templates. It is also exposed to this plugin, so you can have a config stored in the `ecfConfig` metadata collection, or if you prefer to name it something else, you can import it as follows:
70
+ ```js
71
+ eleventyConfig.addPlugin(ecfPlugin, {
72
+ configDataField: 'ecfConfig'
73
+ });
74
+ ```
75
+
76
+ or rely on ecfConfig default and import it without settings:
77
+ ```js
78
+ eleventyConfig.addPlugin(ecfPlugin);
79
+ ```
80
+
81
+ ### 4. Create your search page template
82
+ Everyone's styling and HTML layout for their 11ty projects is going to be different. This plugin permits you to adjust the template to your liking, as long as the main building blocks are there.
83
+
84
+ To get the basic version going, just copy the [example template file](./example/src/filter.njk) into your source directory and change the variables `targetCollection` and `identifyingField`, as well as adjust the `resultItem` macro to match your collection's fields, and you're good to go. For any further modification, rudimentary knowledge of Nunjucks and Eleventy should suffice.
85
+
86
+ If you have suggestions on how to go about improving this step (architecture or instructions), please submit a Pull Request or open an Issue in this repository.
87
+
88
+ ## DecapCMS
89
+ In case you want to expose the configuration file to your CMS, here is the DecapCMS's configuration schema that matches the plugin's config [schema](#configuration-schema).
90
+
91
+ ```yml
92
+ # DecapCMS configuration excerpt.
93
+ collections:
94
+ - label: "Configurations"
95
+ name: "configurations"
96
+ files:
97
+ - label: "Filter Configuration"
98
+ name: "ecfConfig"
99
+ file: "src/_data/ecfConfig.json"
100
+ preview_path: /search
101
+ description: "Filter Configuration"
102
+ extension: json
103
+ format: json
104
+ fields:
105
+ - label: "Configuration Items"
106
+ name: "items"
107
+ widget: list
108
+ summary: "Search Page config for Collection: \"{{ fields.collection }}\""
109
+ allow_add: false
110
+ allow_remove: false
111
+ allow_reorder: false
112
+ fields:
113
+ - label: "Collection ID (string)"
114
+ name: "collection"
115
+ hint: "Codename for the collection that will be filtered"
116
+ widget: string
117
+ required: true
118
+ - label: "Filters"
119
+ name: filters
120
+ widget: "list"
121
+ label_singular: "filter"
122
+ summary: "{{fields.label }} - {{fields.type }}"
123
+ fields:
124
+ - label: "Type"
125
+ name: "type"
126
+ hint: "Type of input for the filter"
127
+ widget: select
128
+ default: "checkbox"
129
+ options: ['radio', 'checkbox', 'text']
130
+ - label: "Label"
131
+ hint: "Display text for a filter"
132
+ name: "label"
133
+ widget: string
134
+ default: ""
135
+ - label: "Field"
136
+ name: "field"
137
+ hint: "Field that the collection will be filtered by (usually lowercase)"
138
+ widget: string
139
+ default: ""
140
+ - label: "External Form ID"
141
+ name: "form"
142
+ widget: string
143
+ hint: "If you are using a custom <form> template, you need to set this string for it to be found by the script"
144
+ default: ""
145
+ required: false
146
+ - label: "Options"
147
+ name: "options"
148
+ widget: object
149
+ default: {}
150
+ required: false
151
+ fields:
152
+ - label: "Placeholder"
153
+ name: "placeholder"
154
+ widget: string
155
+ hint: "Placeholder for text input"
156
+ ```
157
+
158
+ ## TODO
159
+ - NPM publish .gitlab-ci script
160
+ - example website .gitlab-ci script
161
+ - more filter types (number range)
@@ -0,0 +1,197 @@
1
+ /* The following constants must be defined before this script loads: */
2
+ /* global ecfData:readonly -- the data to filter */
3
+ /* global filterConfig:readonly -- configuration for the filter */
4
+
5
+ /**
6
+ * @typedef {Object<string, string | string[]>} StringMap
7
+ * @typedef {StringMap & { _id: string }} Entry
8
+ */
9
+
10
+ /**
11
+ * Defined in the template file as JSON input for the script.
12
+ * @type {Entry[]} */
13
+ globalThis.ecfData;
14
+
15
+ /**
16
+ * Defined in the template file. Direct forward of the filter's JSON config file into a JS Object variable.
17
+ * @type {Object[]} */
18
+ globalThis.filterConfig;
19
+
20
+
21
+ if (typeof ecfData === "undefined") {
22
+ alert('"ecfData" variable must be defined');
23
+ throw new Error("Global `ecfData` must be defined before this script runs.");
24
+ }
25
+
26
+ if (typeof filterConfig === "undefined") {
27
+ alert('"filterConfig" variable must be defined');
28
+ throw new Error("Global `filterConfig` must be defined before this script runs.");
29
+ }
30
+
31
+ /* BEGIN Helper Functions */
32
+ /**
33
+ * Merges `source` into `target`.
34
+ * @param {FormData} target
35
+ * @param {FormData} source
36
+ * @returns {FormData} `target` reference */
37
+ function mergeFormData(target, source) {
38
+ for (const [key, value] of source.entries()) {
39
+ target.append(key, value);
40
+ }
41
+ return target;
42
+ }
43
+ /** @returns {Element|Element[]} element */
44
+ function $(query) {
45
+ let q;
46
+ return (q = document.querySelectorAll(query)).length == 1 ? q[0] : q;
47
+ }
48
+ /* END Helper Functions */
49
+
50
+ /* BEGIN Script */
51
+ const defaultFormId = "ecf-filters";
52
+
53
+ const ecfForms = filterConfig
54
+ .filter(filter => filter.form)
55
+ .map(f => f.form)
56
+ .concat([defaultFormId])
57
+ .map(idString => {
58
+ return document.getElementById(idString);
59
+ });
60
+
61
+ // Minor template Validation
62
+ const formTemplateValidation = ecfForms.every(el => {
63
+ return (el != null && el != undefined && el.tagName.toLowerCase() === 'form');
64
+ });
65
+ if (!formTemplateValidation) {
66
+ alert("Improperly configured template. All <form> elements with IDs from the filter configuration file must exist in the layout.");
67
+ }
68
+
69
+ // mapping each _id to an Element
70
+ const idToElementMap = new Map();
71
+ $("[data-search-id]").forEach(el => {
72
+ idToElementMap.set(el.getAttribute("data-search-id"), el);
73
+ });
74
+
75
+
76
+ /**
77
+ * Maps field name to current filter value from all forms
78
+ * @returns {Promise<Readonly<StringMap>>}
79
+ * */
80
+ async function preProcessFilters() {
81
+ // merge all form data into one
82
+ const formData = ecfForms.reduce((prev, curr) => {
83
+ return mergeFormData(prev, new FormData(curr));
84
+ }, new FormData());
85
+ const filterValues = {};
86
+ for (const [key, value] of formData.entries()) {
87
+ // checkbox
88
+ if (key.slice(0, 5) == "ecf-c") {
89
+ let l = key.split(" ");
90
+ if (!filterValues[l[1]]) {
91
+ filterValues[l[1]] = [];
92
+ }
93
+ if (value == "on") {
94
+ filterValues[l[1]].push(l[2]);
95
+ }
96
+ }
97
+ // text input
98
+ else if (key.slice(0, 5) == "ecf-t") {
99
+ let l = key.split(" ");
100
+ if (value.length > 0) {
101
+ filterValues[l[1]] = value;
102
+ }
103
+ }
104
+ // radio
105
+ else {
106
+ filterValues[key] = value;
107
+ }
108
+ }
109
+ return filterValues;
110
+ }
111
+
112
+ /**
113
+ * Applies `filterValues` to a global `ecfData` array.
114
+ * @param {Promise<StringMap>} filterValues
115
+ * @returns {Promise<Entry[]>} - filtered subset of the `ecfData` array.
116
+ * */
117
+ async function applyFilterValues(filterValues) {
118
+ return ecfData.filter(function(entry) {
119
+ return filterConfig.map((filter) => {
120
+ const filterValue = filterValues[filter.field] || false;
121
+ if (!filterValue) return true;
122
+ switch (filter.type) {
123
+ case 'radio': {
124
+ return entry[filter.field] === filterValue;
125
+ }
126
+ case 'checkbox': {
127
+ return filterValue.every(attr => entry[filter.field].includes(attr));
128
+ }
129
+ case 'text': {
130
+ return entry[filter.field].toLowerCase().indexOf(filterValue) !== -1;
131
+ }
132
+ default: {
133
+ return false;
134
+ }
135
+ }
136
+ }).every(el => (el));
137
+ });
138
+ }
139
+
140
+ /**
141
+ * Hides all the elements of entries that are NOT in the `filteredData` array
142
+ * - uses global `idToElementMap`
143
+ * @param {Entry[]} filteredData - array of filtered ecfData entries
144
+ * */
145
+ async function hideFilteredData(filteredData) {
146
+ // hide all elements
147
+ idToElementMap.values().forEach(element => element.style.display = "none");
148
+ // then unhide elements that were filtered out
149
+ filteredData.forEach(entry => {
150
+ idToElementMap.get(entry._id).style.display = '';
151
+ });
152
+ }
153
+
154
+ /** Run on each filter update */
155
+ function handleFilterInput() {
156
+ preProcessFilters().then(applyFilterValues).then(hideFilteredData);
157
+ }
158
+
159
+ globalThis.selectAllFilterGroup = function selectAllFilterGroup(fieldName) {
160
+ const filter = filterConfig.find(filter => filter.field == fieldName);
161
+ if (filter.type != 'checkbox') {
162
+ alert("Misconfiguration of Filters. Select All button must only be present on checkbox filters.")
163
+ }
164
+ ecfForms.forEach(form => {
165
+ form
166
+ .querySelectorAll('input[type="checkbox"][name^="ecf-c ' + fieldName + '"]')
167
+ .forEach(checkbox => {
168
+ checkbox.checked = true;
169
+ });
170
+ })
171
+ handleFilterInput();
172
+ }
173
+
174
+ globalThis.clearFilterGroup = function clearFilterGroup(fieldName) {
175
+ const filter = filterConfig.find(filter => filter.field == fieldName);
176
+ if (filter.type == 'radio') {
177
+ ecfForms.forEach(form => {
178
+ form
179
+ .querySelectorAll('input[type="radio"][name="' + fieldName + '"]')
180
+ .forEach(radio => { radio.checked = false; });
181
+ })
182
+ }
183
+ if (filter.type == 'checkbox') {
184
+ ecfForms.forEach(form => {
185
+ form
186
+ .querySelectorAll('input[type="checkbox"][name^="ecf-c ' + fieldName + '"]')
187
+ .forEach(checkbox => { checkbox.checked = false; });
188
+ })
189
+ }
190
+ handleFilterInput();
191
+ }
192
+
193
+ ecfForms.forEach(form => {
194
+ form.addEventListener("input", handleFilterInput);
195
+ form.addEventListener("onchange", handleFilterInput);
196
+ form.addEventListener("submit", e => { e.preventDefault() });
197
+ })
@@ -0,0 +1 @@
1
+ if(typeof ecfData==='undefined'){alert('"ecfData" variable must be defined');throw Error('Global `ecfData` must be defined before this script runs.')}if(typeof filterConfig==='undefined'){alert('"filterConfig" variable must be defined');throw Error('Global `filterConfig` must be defined before this script runs.')}function a(b,B){for(const[C,_b] of B.entries())b.append(C,_b);return b}function $(_a){let q;return (q=document.querySelectorAll(_a)).length==1?q[0]:q}var c='ecf-filters',d=filterConfig.filter(D=>D.form).map(f=>f.form).concat([c]).map(_A=>document.getElementById(_A)),A=d.every(E=>(E!=null&&E!=void 0&&E.tagName.toLowerCase()==='form'));!A&&alert('Improperly configured template. All <form> elements with IDs from the filter configuration file must exist in the layout.');var _=new Map;for(const aA of $('[data-search-id]'))_.set(aA.getAttribute('data-search-id'),aA);function g(){var aB=d.reduce((aC,aD)=>a(aC,new FormData(aD)),new FormData),_B={};for(const[aE,aF] of aB.entries())if(aE.slice(0,5)=='ecf-c'){let l=aE.split(' ');!_B[l[1]]&&(_B[l[1]]=[]);aF=='on'&&_B[l[1]].push(l[2])}else if(aE.slice(0,5)=='ecf-t'){let l=aE.split(' ');aF.length>0&&(_B[l[1]]=aF)}else _B[aE]=aF;return _B}function h(aG){return ecfData.filter(aH=>filterConfig.map(aI=>{var aJ=aG[aI.field]||!1;if(!aJ)return!0;switch(aI.type) {case 'radio':return aH[aI.field]===aJ;case 'checkbox':return aJ.every(aK=>aH[aI.field].includes(aK));case 'text':return aH[aI.field].toLowerCase().indexOf(aJ)!==-1;default:return!1}}).every(el=>(el)))}function i(aL){for(const aM of _.values())aM.style.display='none';for(const aN of aL)_.get(aN._id).style.display=''}function j(){g().then(h).then(i)}globalThis.selectAllFilterGroup=function aP(aO){var _c=filterConfig.find(aQ=>aQ.field==aO);_c.type!='checkbox'&&alert('Misconfiguration of Filters. Select All button must only be present on checkbox filters.');for(const aR of d)for(const aS of aR.querySelectorAll(`input[type="checkbox"][name^="ecf-c ${aO}"]`))aS.checked=!0;j()};globalThis.clearFilterGroup=function aU(aT){var _C=filterConfig.find(aV=>aV.field==aT);_C.type=='radio';_C.type=='checkbox';j()};for(const aW of d){aW.addEventListener('input',j);aW.addEventListener('onchange',j);aW.addEventListener('submit',e=>e.preventDefault())}
@@ -0,0 +1,92 @@
1
+ /**
2
+ * @typedef {{type: string, label: string, field: string}} FilterGroupConfig
3
+ * @typedef {{collection: string, filters: Array<FilterGroupConfig> }} FilterCollectionConfig
4
+ * @typedef {{items: Array<FilterCollectionConfig>}} EcfConfig
5
+ * @typedef {{configDataField: string} | {config: EcfConfig}} PluginConfig
6
+ */
7
+
8
+ const defaultConfigFieldName = "ecfConfig";
9
+
10
+ /**
11
+ * @param {Object} [eleventyConfig] - exposed to a plugin by Eleventy by default
12
+ * @param {PluginConfig} [pluginConfig=null] - configuration of the plugin
13
+ */
14
+ function ecfPlugin(eleventyConfig, pluginConfig = null) {
15
+ // config validation
16
+ let configFromMetadata = false;
17
+ let importedConfig = {};
18
+ if (pluginConfig != null) {
19
+ if (pluginConfig.configDataField) {
20
+ configFromMetadata = true;
21
+ }
22
+ else if (pluginConfig.config && typeof pluginConfig.config === 'object') {
23
+ importedConfig = pluginConfig.config;
24
+ }
25
+ else {
26
+ throw new Error("Invalid ECF configuration, missing 'config' or 'configDataField' or 'configFile' property")
27
+ }
28
+ } else {
29
+ // will use "ecfConfig" collection by default
30
+ configFromMetadata = true;
31
+ }
32
+
33
+ /* Processes all entries in all configured collections for unique field values,
34
+ * then duplicates the config into the 'ecfFilters' collection with the 'values' field added. */
35
+ eleventyConfig.addCollection("ecfFilters", function(api) {
36
+ if (configFromMetadata) {
37
+ const configFieldName = pluginConfig.configDataField || defaultConfigFieldName;
38
+ const collection = api.getAll()[0].data[configFieldName];
39
+ if (collection === undefined || collection === null) {
40
+ throw new Error(`ECF configuration collection "${configFieldName}" not detected`)
41
+ }
42
+ importedConfig = collection;
43
+ }
44
+ if (!importedConfig || Object.keys(importedConfig).length == 0) {
45
+ throw new Error("Invalid ECF configuration")
46
+ }
47
+
48
+ if (Object.prototype.hasOwnProperty.call(importedConfig, "items"))
49
+ importedConfig = importedConfig.items;
50
+
51
+ const ecfFiltersCollection = {};
52
+ importedConfig.forEach(function(collectionConfig) {
53
+ let { collection, filters } = collectionConfig;
54
+ // making sure we're not making config collection edits in-place.
55
+ let filterConfig = configFromMetadata ? JSON.parse(JSON.stringify(filters)) : filters;
56
+
57
+ // convert to Set to accept only unique values
58
+ for (const filter of filterConfig) {
59
+ filter.values = new Set();
60
+ }
61
+ const entries = api.getFilteredByTag(collection);
62
+ entries.forEach(entry => {
63
+ for (const filter of filterConfig) {
64
+ switch (filter.type) {
65
+ case "radio": {
66
+ let value = entry.data[filter.field];
67
+ let strValue = typeof value !== 'string' ? value.toString() : value;
68
+ filter.values.add(strValue);
69
+ break;
70
+ }
71
+ case "checkbox": {
72
+ entry.data[filter.field]?.forEach(value => {
73
+ let strValue = typeof value !== 'string' ? value.toString() : value;
74
+ filter.values?.add(strValue)
75
+ });
76
+ break;
77
+ }
78
+ }
79
+ }
80
+ });
81
+
82
+ // converting from a Set into Array so templates can read it
83
+ for (const filter of filterConfig) {
84
+ filter.values = [...filter.values].filter(val => val != undefined && val != null);
85
+ }
86
+ ecfFiltersCollection[collection] = filterConfig;
87
+ });
88
+ return ecfFiltersCollection;
89
+ });
90
+ }
91
+
92
+ module.exports = ecfPlugin;
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "eleventy-plugin-filter-page",
3
+ "version": "0.1.0",
4
+ "description": "11ty plugin to create collection filter pages",
5
+ "main": "dist/ecf.plugin.js",
6
+ "files": [
7
+ "dist/",
8
+ "README.md",
9
+ "LICENSE"
10
+ ],
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://gitlab.com/excitedfellas/eleventy-plugin-filter-page.git"
14
+ },
15
+ "bugs": {
16
+ "url": "https://gitlab.com/excitedfellas/eleventy-plugin-filter-page/-/issues"
17
+ },
18
+ "scripts": {
19
+ "lint": "eslint",
20
+ "build": "rm -rf dist && mkdir -p dist && cp -R src/. dist/ && minify dist/ecf.filter.js > dist/ecf.filter.min.js",
21
+ "prepublishOnly": "npm run build"
22
+ },
23
+ "homepage": "https://gitlab.com/excitedfellas/eleventy-plugin-filter-page",
24
+ "keywords": [
25
+ "11ty",
26
+ "eleventy",
27
+ "javascript",
28
+ "plugin"
29
+ ],
30
+ "author": "Daniel Mayovskiy",
31
+ "license": "MIT",
32
+ "devDependencies": {
33
+ "@eslint/js": "^9.39.2",
34
+ "eslint": "^9.39.2",
35
+ "globals": "^17.0.0",
36
+ "minify": "^11.0.0"
37
+ }
38
+ }