diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 0ad8b6d..2ccd865 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -44,9 +44,10 @@ module.exports = { // 'jest/globals': true, // }, globals: { + beforeEach: 'readonly', describe: 'readonly', - it: 'readonly', expect: 'readonly', + it: 'readonly', }, }], }; diff --git a/.jest/jest.config.cjs b/.jest/jest.config.cjs index 2c45a0f..8dba07b 100644 --- a/.jest/jest.config.cjs +++ b/.jest/jest.config.cjs @@ -22,6 +22,7 @@ module.exports = { }, }, rootDir: resolve(__dirname, '../'), + setupFiles: ['/.jest/jest.setup.cjs'], setupFilesAfterEnv: [], testMatch: [ '/src/**/__tests__/**/*.test.js', diff --git a/.jest/jest.setup.cjs b/.jest/jest.setup.cjs new file mode 100644 index 0000000..b6a6d02 --- /dev/null +++ b/.jest/jest.setup.cjs @@ -0,0 +1 @@ +require( 'jest-localstorage-mock'); diff --git a/.vscode/settings.json b/.vscode/settings.json index 246e9c6..58aaa72 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,5 @@ { "editor.codeActionsOnSave": { - "source.fixAll.eslint": true + "source.fixAll.eslint": "explicit" } } diff --git a/package-lock.json b/package-lock.json index 2186c07..789e3b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "recipes-nodejs", - "version": "1.0.3", + "version": "1.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "recipes-nodejs", - "version": "1.0.3", + "version": "1.0.4", "license": "MIT", "dependencies": { "pretty": "^2.0.0", @@ -21,6 +21,7 @@ "eslint-config-airbnb-base": "^15.0.0", "eslint-plugin-import": "^2.29.0", "jest": "^29.7.0", + "jest-localstorage-mock": "^2.4.26", "nodemon": "^3.0.1" } }, @@ -4403,6 +4404,15 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-localstorage-mock": { + "version": "2.4.26", + "resolved": "https://registry.npmjs.org/jest-localstorage-mock/-/jest-localstorage-mock-2.4.26.tgz", + "integrity": "sha512-owAJrYnjulVlMIXOYQIPRCCn3MmqI3GzgfZCXdD3/pmwrIvFMXcKVWZ+aMc44IzaASapg0Z4SEFxR+v5qxDA2w==", + "dev": true, + "engines": { + "node": ">=6.16.0" + } + }, "node_modules/jest-matcher-utils": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", diff --git a/package.json b/package.json index 564f703..f9ab424 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "recipes-nodejs", - "version": "1.0.3", + "version": "1.0.4", "description": "NodeJS recipe publisher, converts markdown/text recipe files into well formatted HTML.", "main": "build.js", "type": "module", @@ -32,6 +32,7 @@ "eslint-config-airbnb-base": "^15.0.0", "eslint-plugin-import": "^2.29.0", "jest": "^29.7.0", + "jest-localstorage-mock": "^2.4.26", "nodemon": "^3.0.1" }, "repository": { diff --git a/src/buildRecipes.js b/src/buildRecipes.js index de886c4..9e68f0c 100644 --- a/src/buildRecipes.js +++ b/src/buildRecipes.js @@ -90,9 +90,9 @@ const RegExes = Object.freeze({ const LINK_SUB_NAME = ''; -function setHeadMeta(documentHtml, { favicon, ogImgURL, recipeName, titleSuffix }) { +function setHeadMeta(documentHtml, { author, favicon, ogImgURL, recipeName, titleSuffix }) { return documentHtml - .replace(RegExes.PAGE_TITLE, `${recipeName}${titleSuffix || ''}`) + .replace(RegExes.PAGE_TITLE, `${recipeName}${author ? ` by ${author}` : ''}${titleSuffix || ''}`) .replace(Substitutions.META_DATE, ``) .replace(Substitutions.META_OG_IMG, ogImgURL ? `` : '') .replace(Substitutions.META_FAVICON, favicon ? `` : '') @@ -175,7 +175,7 @@ function getHelpSection(helpURLs, name) { function convertRecipe(outputHTML, recipeHTML, opts) { const { - name, + author, name, heroImgURL: image, autoUrlSections, defaultTheme, favicon, useFractionSymbols, helpURLs, includeHelpLinks, shortenURLs, titleSuffix, } = opts; @@ -227,7 +227,7 @@ function convertRecipe(outputHTML, recipeHTML, opts) { outputHTML = sectionMgr.replace(outputHTML); - return setHeadMeta(outputHTML, { favicon, ogImgURL: heroImgURL, recipeName, titleSuffix }) + return setHeadMeta(outputHTML, { author, favicon, ogImgURL: heroImgURL, recipeName, titleSuffix }) .replace(Substitutions.HELP, showHelp ? getHelpSection(helpURLs, name) : '') .replace(Substitutions.HERO_IMG, heroImgURL ? `` : '') .replace(Substitutions.THEME_CSS, `theme-${customizations.style || defaultTheme}`) @@ -267,7 +267,7 @@ export default function buildRecipes(recipeTemplate, options, fileList, images) if (addImageLinks) { html = linkifyImages(html); } - html = prettyHtml(convertRecipe(recipeTemplate, html, { ...options, name, heroImgURL }), { ocd: true }); + html = prettyHtml(convertRecipe(recipeTemplate, html, { ...options, author, heroImgURL, name }), { ocd: true }); writeFile(resolve(outputPath, `${name}.html`), html, { encoding: 'utf8' }, () => { if (fileList.length === ++fileCount) { promResolve(recipeInfo); diff --git a/src/libs/utils.js b/src/libs/utils.js index c0de3cd..3721f76 100644 --- a/src/libs/utils.js +++ b/src/libs/utils.js @@ -23,7 +23,7 @@ const RegExes = { // #endregion // #region Find Author Credit - AUTHOR: /^(?:#{3,6})?\s*(?:recipe )?(?:adapted by|author|by|courtesy(?: of)?|from(?: the)? kitchen of|from|source)\s*[ :-]\s*([A-Z][\w '"]+)/im, + AUTHOR: /^(?:#{3,6})?\s*(?:recipe )?(?:adapted (?:by|from)|author|by|courtesy(?: of)?|from(?: the)? kitchen of|from|source)\s*[ :-]\s*([A-Z][\w '"]+)/im, // #endregion }; diff --git a/src/static/images/icons/recents.svg b/src/static/images/icons/recents.svg new file mode 100644 index 0000000..886115b --- /dev/null +++ b/src/static/images/icons/recents.svg @@ -0,0 +1,6 @@ + + + diff --git a/src/static/scripts/index.js b/src/static/scripts/index.js index 63a456f..1538877 100644 --- a/src/static/scripts/index.js +++ b/src/static/scripts/index.js @@ -1,5 +1,6 @@ -import { init as initViewBtns } from './libs/viewPicker.js'; +import { init as initRecentlyViewed } from './libs/recentlyViewed.js'; import { init as initSearchBox } from './libs/searchBox.js'; +import { init as initViewBtns } from './libs/viewPicker.js'; import { KeyNames, getKey } from './libs/preferences.js'; function run() { @@ -8,6 +9,7 @@ function run() { initSearchBox(search); initViewBtns(view); + initRecentlyViewed(); } run(); diff --git a/src/static/scripts/libs/__tests__/preferences.test.js b/src/static/scripts/libs/__tests__/preferences.test.js new file mode 100644 index 0000000..594e72c --- /dev/null +++ b/src/static/scripts/libs/__tests__/preferences.test.js @@ -0,0 +1,97 @@ +import { + KeyNames, + updateKey, + getKey, + updateMRUList, +} from '../preferences'; + +describe('preferences', () => { + beforeEach(() => { + localStorage.clear(); + }); + + describe('updateKey', () => { + it('should update the value of a key in the app data', () => { + const key = KeyNames.RECENT; + const value = ['recipe1', 'recipe2']; + + updateKey(key, value); + + const updatedValue = getKey(key); + expect(updatedValue).toEqual(value); + }); + }); + + describe('getKey', () => { + it('should return the default value when `key` has not been set', () => { + const key = KeyNames.SEARCH; + const defaultValue = 'default'; + + const value = getKey(key, defaultValue); + expect(value).toEqual(defaultValue); + }); + + it('should return the default value if the key does not exist', () => { + const key = 'nonExistentKey'; + const defaultValue = 'default'; + + const value = getKey(key, defaultValue); + expect(value).toEqual(defaultValue); + }); + }); + + describe('updateMRUList', () => { + it('should add a value to the MRU list', () => { + const key = KeyNames.RECENT; + const maxLength = 3; + const value = 'recipe1'; + + updateMRUList(key, maxLength, value); + + const updatedList = getKey(key); + expect(updatedList).toEqual([value]); + }); + + it('should place new values at top of the MRU list', () => { + const key = KeyNames.RECENT; + const maxLength = 3; + const value1 = 'recipe1'; + const value2 = 'recipe2'; + + updateMRUList(key, maxLength, value1); + updateMRUList(key, maxLength, value2); + + const updatedList = getKey(key); + expect(updatedList).toEqual([value2, value1]); + }); + + it('should move an existing value to the top of the MRU list', () => { + const key = KeyNames.RECENT; + const maxLength = 3; + const value1 = 'recipe1'; + const value2 = 'recipe2'; + + updateMRUList(key, maxLength, value1); + updateMRUList(key, maxLength, value2); + updateMRUList(key, maxLength, value1); + + const updatedList = getKey(key); + expect(updatedList).toEqual([value1, value2]); + }); + + it('should discard the last item if the MRU list exceeds the max length', () => { + const key = KeyNames.RECENT; + const maxLength = 2; + const value1 = 'recipe1'; + const value2 = 'recipe2'; + const value3 = 'recipe3'; + + updateMRUList(key, maxLength, value1); + updateMRUList(key, maxLength, value2); + updateMRUList(key, maxLength, value3); + + const updatedList = getKey(key); + expect(updatedList).toEqual([value3, value2]); + }); + }); +}); diff --git a/src/static/scripts/libs/__tests__/recentlyViewed.test.js b/src/static/scripts/libs/__tests__/recentlyViewed.test.js new file mode 100644 index 0000000..5256fdf --- /dev/null +++ b/src/static/scripts/libs/__tests__/recentlyViewed.test.js @@ -0,0 +1,20 @@ +import { toDisplay } from '../recentlyViewed'; + +describe('recentlyViewed', () => { + describe('toDisplay', () => { + const tests = [ + { value: 'hello-world', expectedResult: 'Hello World' }, + { value: 'hello world', expectedResult: 'Hello World' }, + { value: ' HeLLo WoRLD ', expectedResult: 'Hello World' }, + { value: '', expectedResult: '' }, + { value: '---', expectedResult: '' }, + ]; + + tests.forEach((test) => { + it(`should convert "${test.value}" to "${test.expectedResult}"`, () => { + const result = toDisplay(test.value); + expect(result).toEqual(test.expectedResult); + }); + }); + }); +}); diff --git a/src/static/scripts/libs/__tests__/searchBox.test.js b/src/static/scripts/libs/__tests__/searchBox.test.js new file mode 100644 index 0000000..ac8c35b --- /dev/null +++ b/src/static/scripts/libs/__tests__/searchBox.test.js @@ -0,0 +1,21 @@ +import { scrub } from '../searchBox'; + +describe('searchBox', () => { + describe('scrub', () => { + const tests = [ + { value: 'Hello World!', expectedResult: 'helloworld' }, + { value: ' A very delicious pumpkin -- pie! From, :Hasbro (99) ', expectedResult: 'averydeliciouspumpkinpiefromhasbro99' }, + { value: ' Hello World ', expectedResult: 'helloworld' }, + { value: '12345', expectedResult: '12345' }, + { value: '', expectedResult: '' }, + { value: '---', expectedResult: '' }, + ]; + + tests.forEach((test) => { + it(`should scrub "${test.value}" to "${test.expectedResult}"`, () => { + const result = scrub(test.value); + expect(result).toEqual(test.expectedResult); + }); + }); + }); +}); diff --git a/src/static/scripts/libs/preferences.js b/src/static/scripts/libs/preferences.js index 062763e..244770a 100644 --- a/src/static/scripts/libs/preferences.js +++ b/src/static/scripts/libs/preferences.js @@ -3,8 +3,14 @@ const StorageKeys = { }; export const KeyNames = Object.freeze({ - VIEW: 'content', + /** most recently viewed recipe list */ + RECENT: 'recent', + /** last search term */ SEARCH: 'search', + /** most recent search term list */ + SEARCH_HISTORY: 'searchHistory', + /** most recently selected view/layout */ + VIEW: 'content', }); const setAppData = (payload) => localStorage.setItem(StorageKeys.APP_NAME, JSON.stringify(payload)); @@ -34,3 +40,27 @@ export const getKey = (key, defaultValue) => { const value = payload[key]; return value === undefined ? defaultValue : value; }; + +/** + * Add `value` in first position of list named `key`. When list length + * exceeds `maxLength` last item is discarded. + */ +export const updateMRUList = (key, maxLength, value) => { + let list = getKey(key, []); + const index = list.indexOf(value); + if (index === 0) { + return; + } + + if (index > -1) { + list.splice(index, 1); + } + + list.unshift(value); + + if (list.length > maxLength) { + list = list.slice(0, maxLength); + } + + updateKey(key, list); +}; diff --git a/src/static/scripts/libs/recentlyViewed.js b/src/static/scripts/libs/recentlyViewed.js new file mode 100644 index 0000000..69571ce --- /dev/null +++ b/src/static/scripts/libs/recentlyViewed.js @@ -0,0 +1,58 @@ +import { KeyNames, getKey, updateMRUList } from './preferences.js'; + +const MAX_LIST_LENGTH = 50; + +/* eslint-disable key-spacing */ +const Selectors = { + RECIPE_LIST: '#recipe-list', + + RECENTLY_VIEWED_BTN: '#show-recents-btn', + + MODAL: '#show-recents-modal', + MODAL_CONTENT: '#show-recents-content', + MODAL_CLOST_BTN: '#show-recents-close-btn', +}; +/* eslint-enable key-spacing */ + +const Styles = { + MODAL_ACTIVE: 'modal--is-active', +}; + +export const toDisplay = (value) => value + .replace(/-/g, ' ') + .replace(/\w\S*/g, (txt) => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase()) + .replace(/\s+/g, ' ') + .trim(); + +function onRecipeLinkClick(e) { + const anchor = e.target.tagName === 'A' + ? e.target + : e.target.closest('a'); + + if (anchor) { + const value = anchor.getAttribute('href'); + if (value) { + updateMRUList(KeyNames.RECENT, MAX_LIST_LENGTH, value.replace(/^.*\/|\.html$/g, '')); + } + } +} + +function toggleModal(isActive) { + const modal = document.querySelector(Selectors.MODAL); + modal.classList.toggle(Styles.MODAL_ACTIVE, isActive); + modal.setAttribute('aria-hidden', !isActive); +} + +function onViewBtnClick() { + toggleModal(true); + document.querySelector(Selectors.MODAL_CONTENT).innerHTML = getKey(KeyNames.RECENT) + .map((value) => `
  • ${toDisplay(value)}
  • `) + .join(''); +} + +export function init() { + document.querySelector(Selectors.RECENTLY_VIEWED_BTN).addEventListener('click', onViewBtnClick); + // event delegation: record all recipe link clicks + document.querySelector(Selectors.RECIPE_LIST).addEventListener('click', onRecipeLinkClick); + document.querySelector(Selectors.MODAL_CLOST_BTN).addEventListener('click', () => toggleModal(false)); +} diff --git a/src/static/scripts/libs/searchBox.js b/src/static/scripts/libs/searchBox.js index b8bfae9..196d0c0 100644 --- a/src/static/scripts/libs/searchBox.js +++ b/src/static/scripts/libs/searchBox.js @@ -15,9 +15,9 @@ const Selectors = { const KeyCodes = { ESCAPE: 27, }; - /* eslint-enable key-spacing */ +/* eslint-enable key-spacing */ -const scrub = (value) => value +export const scrub = (value) => value .trim() .toLowerCase() .replace(/\W/g, '') diff --git a/src/static/scripts/libs/utils.js b/src/static/scripts/libs/utils.js new file mode 100644 index 0000000..6c72278 --- /dev/null +++ b/src/static/scripts/libs/utils.js @@ -0,0 +1,8 @@ +export const debounce = (func, delay) => { + let timeoutId; + + return (...args) => { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => func.apply(this, args), delay); + }; +}; diff --git a/src/static/styles/recentsModal.css b/src/static/styles/recentsModal.css new file mode 100644 index 0000000..05d3bdb --- /dev/null +++ b/src/static/styles/recentsModal.css @@ -0,0 +1,66 @@ +.modal { + background-color: var(--color-neutral-00); + bottom: 0; + display: none; + left: 0; + padding: 20px; + position: absolute; + right: 0; + top: 0; + z-index: var(--z-index-modal); +} + +.modal--is-active { + display: block; +} + +.modal__title { + text-transform: capitalize; + color: #666; +} + +.modal__content a { + display: block; + padding: .54em 0; + text-decoration: none; +} + +.modal__close-btn { + background: transparent; + border: none; + cursor: pointer; + fill: #000; + height: 16px; + padding: 0; + position: absolute; + right: 10px; + top: 10px; + width: 16px; +} + +.modal__close-btn:hover { + fill: #666; +} + +.modal__close-icon { + width: 100%; + height: 100%; +} + +@media screen and (min-width: 580px) { + .modal { + border-radius: var(--default-border-radius); + border: solid 1px var(--color-filter-hover); + bottom: unset; + box-shadow: 5px 8px 8px rgba(0,0,0,.2); + left: 0; + right: unset; + top: 0; + } + + .modal.modal--recents { + top: 50px; + left: 10px; + max-width: 300px; + } +} diff --git a/src/static/styles/recipesIndex.css b/src/static/styles/recipesIndex.css index 890dfb3..1dba2bb 100644 --- a/src/static/styles/recipesIndex.css +++ b/src/static/styles/recipesIndex.css @@ -8,10 +8,13 @@ --color-filter-hover: var(--color-neutral-80); --color-filter-focus: var(--color-neutral-50); - --search-input-height: 40px; - --search-icon-length: 18px; + --default-border-radius: 4px; --small-btn-length: 24px; + + --search-input-height: 40px; + --search-icon-length: 18px; + --search-input-max-width: calc(14em + var(--small-btn-length) + var(--search-icon-length)); } .mainTitle { @@ -58,7 +61,7 @@ html body .nav-view__item { .nav-view__btn { background-color: transparent; border: solid 1px var(--color-view-btn-inactive); - border-radius: 4px; + border-radius: var(--default-border-radius); padding: 0; display: inline-block; width: 26px; @@ -97,6 +100,19 @@ html body .nav-view__item { width: 24px; } +/* */ +.btn__show-recent { + position: absolute; + top: 10px; + left: 10px; + width: 25px; + height: 25px; +} + +.icon--recents { + width: 80%; +} + /* Filter/Search */ .filter__wrap { float: right; @@ -120,7 +136,7 @@ html body .nav-view__item { .filter__text-field:not(:placeholder-shown), .filter__text-field:focus { border-color: var(--color-filter-focus); - width: calc(14em + var(--small-btn-length) + var(--search-icon-length)); + width: var(--search-input-max-width); } .filter__icon { @@ -143,7 +159,7 @@ html body .nav-view__item { .filter__cancel-btn { border: solid 1px var(--color-filter-focus); - border-radius: 4px; + border-radius: var(--default-border-radius); cursor: pointer; display: none; height: var(--small-btn-length); diff --git a/src/static/styles/vars.css b/src/static/styles/vars.css index d8a50da..0187b2c 100644 --- a/src/static/styles/vars.css +++ b/src/static/styles/vars.css @@ -62,6 +62,8 @@ /*-----*: Transition Speeds :*-----*/ --transition-color-duration: 350ms; + /* z-index */ + --z-index-modal: 1; /** *-----*: Device break points :*-----* diff --git a/src/templates/index.html b/src/templates/index.html index 4e25e1c..167d988 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -15,10 +15,32 @@ + + + + + + + + + + + +
    @@ -28,29 +50,40 @@

    Recipe Book

    Jump To Recipes

    {{__letters-index__}} + + + +