diff --git a/build.js b/build.js index a565c32..80aa681 100644 --- a/build.js +++ b/build.js @@ -156,7 +156,7 @@ function main(configs) { readFile(resolve(templatesPath, 'index.html'), { encoding: 'utf8' }), readFile(resolve(templatesPath, 'recipe.html'), { encoding: 'utf8' }), ]) - .then(([markdownFiles, images, indexTemplate, recipeTemplate]) => { + .then(async ([markdownFiles, images, indexTemplate, recipeTemplate]) => { markdownFiles = filterByExtension(markdownFiles, recipesPath, ['.md']); images = filterByExtension(images, imagesPath, ['.jpg', '.jpeg', '.png', '.webp', '.avif']); @@ -165,8 +165,8 @@ function main(configs) { makeThumbnails(outputPath, images); images = swapJpegForAvif(images); - buildRecipes(recipeTemplate, options, markdownFiles, images); - buildRecipeIndex(indexTemplate, options, markdownFiles, images); + const recipeInfo = await buildRecipes(recipeTemplate, options, markdownFiles, images); + buildRecipeIndex(indexTemplate, options, markdownFiles, images, recipeInfo); const endTime = new Date(); console.log(`Processed ${markdownFiles.length} recipes in ${endTime - startTime}ms`); diff --git a/config.js b/config.js index c720134..c17a59c 100644 --- a/config.js +++ b/config.js @@ -24,6 +24,18 @@ module.exports = { */ useSmartQuotes: true, + /** + * **experimental** + * when enabled wraps `` tags in link to open in new tab + */ + addImageLinks: true, + + /** + * **experimental** + * when enabled looks in raw file for author's name + */ + findAuthor: true, + defaultTheme: 'default', /** diff --git a/src/buildRecipeIndex.js b/src/buildRecipeIndex.js index 20e37b9..e6c17f0 100644 --- a/src/buildRecipeIndex.js +++ b/src/buildRecipeIndex.js @@ -9,14 +9,16 @@ const Substitutions = { // Head's meta-tags META_FAVICON: '{{__favIcon__}}', META_DATE: '{{__metaDateGenerated__}}', - META_OG_IMG: '{{__metaOGImage__}}', + META_OG_IMG: '{{__metaOGImage__}}', THEME_CSS: '{{__theme__}}', // kickoff the on-page script STARTUP_JS: '{{__startup__}}', }; const Styles = Object.freeze({ + AUTHOR: 'recipe-list__author', ITEM: 'recipe-list__item', + LINK: 'recipe-list__item-link', NAME: 'recipe-list__name', PHOTO: 'recipe-list__photo', }); @@ -30,7 +32,7 @@ const RegExes = { * and generate table of contents, plus a quick-nav list * at the top */ -function buildRecipeIndex(indexTemplate, { defaultTheme, favicon, outputPath, initialIndexView }, fileList, images) { +function buildRecipeIndex(indexTemplate, { defaultTheme, favicon, outputPath, initialIndexView }, fileList, images, recipeInfo) { // create anchor and name from url let lettersIndex = ''; // create list of recipes @@ -55,14 +57,17 @@ function buildRecipeIndex(indexTemplate, { defaultTheme, favicon, outputPath, in if (!ogImgURL && image) { ogImgURL = `images/${image.fileName}`; } + const author = recipeInfo.find(({ name: infoName }) => infoName === name)?.author || ''; const imgPath = image ? `images/thumbnails/${image.name}.jpg` : 'images/placeholder.svg'; recipeItems += ` - + - ${displayName} + ${displayName} + ${author ? `${author}` : ''} + `.replace(RegExes.END_SPACES, ''); diff --git a/src/buildRecipes.js b/src/buildRecipes.js index cb90545..b625ca7 100644 --- a/src/buildRecipes.js +++ b/src/buildRecipes.js @@ -2,7 +2,7 @@ const { resolve } = require('path'); const { readFile, writeFile } = require('fs'); const showdown = require('showdown'); const prettyHtml = require('pretty'); -const { linkify, shorten, replaceFractions, replaceQuotes } = require('./libs/utils'); +const { linkify, shorten, replaceFractions, replaceQuotes, linkifyImages, getAuthor } = require('./libs/utils'); const SectionMgr = require('./libs/SectionManager'); /* eslint-disable key-spacing */ @@ -132,7 +132,7 @@ const getInlineCss = heroImgURL => !heroImgURL : ` `; @@ -169,10 +169,12 @@ function getHelpSection(helpURLs, name) { `; } -function convertRecipe(outputHTML, recipeHTML, config, name, image) { +function convertRecipe(outputHTML, recipeHTML, opts) { const { + name, + heroImgURL: image, autoUrlSections, defaultTheme, favicon, useFractionSymbols, helpURLs, includeHelpLinks, shortenURLs, titleSuffix, - } = config; + } = opts; let recipeName = ''; const sectionMgr = new SectionMgr({ definedTypes: SectionTypes, defaultType: SectionTypes.NOTES }); @@ -235,24 +237,37 @@ function convertRecipe(outputHTML, recipeHTML, config, name, image) { } function buildRecipes(recipeTemplate, options, fileList, images) { - const { outputPath, useSmartQuotes } = options; - - const converter = new showdown.Converter(); - - fileList.forEach(({ file: path, name }) => { - readFile(path, { encoding: 'utf8' }, (err, markdown) => { - if (err) { - // eslint-disable-next-line no-console - console.error(err); - return; - } - const heroImgURL = images.find(i => i.name === name); - if (useSmartQuotes) { - markdown = replaceQuotes(markdown); - } - let html = converter.makeHtml(markdown); - html = prettyHtml(convertRecipe(recipeTemplate, html, options, name, heroImgURL), { ocd: true }); - writeFile(resolve(outputPath, `${name}.html`), html, { encoding: 'utf8'}, () => null); + return new Promise((promResolve) => { + let fileCount = 0; + const { addImageLinks, findAuthor, outputPath, useSmartQuotes } = options; + const converter = new showdown.Converter(); + const recipeInfo = []; + fileList.forEach(({ file: path, name }) => { + readFile(path, { encoding: 'utf8' }, (err, markdown) => { + if (err) { + // eslint-disable-next-line no-console + console.error(err); + return; + } + const heroImgURL = images.find(i => i.name === name); + if (useSmartQuotes) { + markdown = replaceQuotes(markdown); + } + const author = findAuthor ? getAuthor(markdown) : ''; + if (author) { + recipeInfo.push({ name, author }); + } + let html = converter.makeHtml(markdown); + if (addImageLinks) { + html = linkifyImages(html); + } + html = prettyHtml(convertRecipe(recipeTemplate, html, { ...options, name, heroImgURL }), { ocd: true }); + writeFile(resolve(outputPath, `${name}.html`), html, { encoding: 'utf8'}, () => { + if (fileList.length === ++fileCount) { + promResolve(recipeInfo); + } + }); + }); }); }); } diff --git a/src/libs/__tests__/utils.test.js b/src/libs/__tests__/utils.test.js index de9565e..31cbc37 100644 --- a/src/libs/__tests__/utils.test.js +++ b/src/libs/__tests__/utils.test.js @@ -127,4 +127,82 @@ describe('buildRecipes', () => { expect(result).toBe(expectedResult); }); }); + + describe('getAuthor', () => { + const { getAuthor } = utils; + + it('should happy path', () => { + const Tests = [{ + value: ``, + expectedResult: '', + }, { + value: ` + by Amanda Berry + `, + expectedResult: 'Amanda Berry', + }, { + value: ` +photo by Demples +This is from the kitchen of Harriet McCormmick hereself +from the kitchen of Aunt Bertha (my favorite auntie) + + `, + expectedResult: 'Aunt Bertha', + }, { + value: ` + courtesy of: Jenny + `, + expectedResult: 'Jenny', + }, { + value: ` + courtesy of Derek + `, + expectedResult: 'Derek', + }, { + value: `BY Todd`, + expectedResult: 'Todd', + }, { + value: `BY the New York Times Staff`, + expectedResult: '', + }, { + value: `From the time my Unkle James was paroled`, + expectedResult: '', + }, { + value: ` + courtesy of : Gavin + + `, + expectedResult: 'Gavin', + }, { + value: ` + courtesy of:Auntie Jim + + `, + expectedResult: 'Auntie Jim', + }, { + value: ` +from Mellisa Clark at the New York Times + `, + expectedResult: 'Mellisa Clark at the New York Times', + }, { + value: ` +by Jeff "Handsy" Smith aka "The Frugal Gourmet" (WBEZ Chicago) + `, + expectedResult: 'Jeff "Handsy" Smith aka "The Frugal Gourmet"', + }, { + value: ` +# Positively-the-Absolutely-Best-Chocolate-Chip Cookies +### From Maida Heatter + +* Yield: **50** cookies. +`, + expectedResult: 'Maida Heatter' + }]; + + Tests.forEach(({ value, expectedResult}) => { + const result = getAuthor(value); + expect(result).toBe(expectedResult); + }); + }); + }); }); diff --git a/src/libs/utils.js b/src/libs/utils.js index 723f68f..b73704e 100644 --- a/src/libs/utils.js +++ b/src/libs/utils.js @@ -8,6 +8,11 @@ const RegExes = { EMAIL: /(([a-zA-Z0-9\-_.])+@[a-zA-Z_]+?(\.[a-zA-Z]{2,6})+)/gim, // #endregion + // #region LInkify Images + IMG_TAG: /]+?>/, + IMG_ALT_PROP: /\balt="([^"]+?)"/, + // #endregion + // #region Shorten textReg: /(?<=>)(https?:\/\/.*?)(?=<\/a>)/, simpleDomain: /((?:[\w-]+\.)+[\w-]+)/, @@ -16,8 +21,17 @@ const RegExes = { // #region replaceFractions FRACTIONS: /(1\/[2-9]|2\/[35]|3\/[458]|4\/5|5\/[68])|7\/8/g, // #endregion + + // #region Find Author Credit + AUTHOR: /^(?:#{3,6})?\s*(?:by|courtesy of|from(?: the)? kitchen of|from)\s*[ :-]\s*([A-Z][\w "]+)/im + // #endregion }; +const Styles = Object.freeze({ + LINKED_IMG: 'img-link', + JS_LINKED_IMG: 'js-img-link', +}); + const FractionsHash = Object.freeze({ '1/2': '½', '1/3': '⅓', @@ -46,6 +60,25 @@ const linkify = value => value .replace(RegExes.URL_WITH_WWW, '$1$2') .replace(RegExes.EMAIL, '$1'); +/** + * Violating one of my goals to keep this "pure" -- all front-end + * aganostic, but should apply a class for future me to tinker. + */ +const linkifyImages = text => text + .replace(new RegExp(RegExes.IMG_TAG, 'gm'), (imgTag) => { + const [, src] = imgTag.match(RegExes.IMG_TAG); + const [, alt] = imgTag.match(RegExes.IMG_ALT_PROP) || []; + return `${imgTag}`; + }); + +const getAuthor = (text) => { + let [, author] = `${text}`.match(RegExes.AUTHOR) || []; + if (!author) { + return ''; + } + author = author.trim(); + return /[A-Z]/.test(author[0]) ? author : ''; +}; /** * Replaces complete URL with only the domain, i.e. strips * off path & protocol. @@ -65,7 +98,9 @@ const replaceFractions = value => value const replaceQuotes = value => value.replace(/(?]+)"(?=[\s<])/g, '“$1”'); module.exports = { + getAuthor, linkify, + linkifyImages, replaceFractions, replaceQuotes, shorten, diff --git a/src/static/styles/recipesIndex.css b/src/static/styles/recipesIndex.css index b5a1a25..decfb18 100644 --- a/src/static/styles/recipesIndex.css +++ b/src/static/styles/recipesIndex.css @@ -4,9 +4,9 @@ --color-view-btn-selected: var(--color-primary); --color-view-svg-selected: var(--color-neutral-00); - --color-filter-inactive: var(--color-neutral-25); - --color-filter-hover: var(--color-neutral-80); - --color-filter-focus: var(--color-neutral-50); + --color-filter-inactive: var(--color-neutral-25); + --color-filter-hover: var(--color-neutral-80); + --color-filter-focus: var(--color-neutral-50); --search-input-height: 40px; --search-icon-length: 18px; @@ -192,11 +192,17 @@ body .recipe-list .recipe-list__item--hidden { text-transform: capitalize; } -.recipe-list__item a { +.recipe-list__item-link { display: inline-block; + text-decoration: none; width: 100%; } +.recipe-list__item-link:link, +.recipe-list__item-link:visited { + text-decoration: inherit; +} + .recipe-list__photo img { object-fit: cover; width: 100%; @@ -207,6 +213,14 @@ body .recipe-list .recipe-list__item--hidden { display: inline-block; } +.recipe-list__author { + color: var(--color-neutral-50); + display: block; + font-size: 90%; + font-style: normal; + font-weight: 300; +} + /* Shared */ .view--content .recipe-list__item, .view--compact-list .recipe-list__item { @@ -219,8 +233,8 @@ body .recipe-list .recipe-list__item--hidden { break-inside: avoid-column; } - /* view: Compact List */ +.view--compact-list .recipe-list__author, .view--compact-list .recipe-list__photo { display: none; } @@ -254,13 +268,13 @@ body .recipe-list .recipe-list__item--hidden { .view--grid .recipe-list__item a { height: 260px; - border: solid 1px transparent; + border: solid 1px var(--color-neutral-15); border-radius: 6px; overflow: hidden; } .view--grid .recipe-list__item a:hover { - border-color: black; + border-color: var(--color-neutral-100); } .view--grid .recipe-list__photo { @@ -270,10 +284,12 @@ body .recipe-list .recipe-list__item--hidden { } .view--grid .recipe-list__name { - padding: 0 1em; + -webkit-line-clamp: 2; font-size: 110%; font-weight: 700; line-height: 1.4; + overflow: hidden; + padding: 0 1em; text-overflow: ellipsis; } diff --git a/src/static/styles/stylesheet.css b/src/static/styles/stylesheet.css index 911095b..dc38a18 100644 --- a/src/static/styles/stylesheet.css +++ b/src/static/styles/stylesheet.css @@ -252,6 +252,10 @@ a:hover { background-color: var(--color-link-bg-hover); } +img { + max-width: 100%; +} + /* specific sections/objects */ .wrapper { width: 90%; diff --git a/src/static/styles/theme-default.css b/src/static/styles/theme-default.css index 765cfc5..1e9445b 100644 --- a/src/static/styles/theme-default.css +++ b/src/static/styles/theme-default.css @@ -1,7 +1,7 @@ @import url('https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,300;1,300&family=PT+Sans+Narrow:wght@700&display=swap'); :root { - --font-stack-default-body: 'Open Sans', Franklin, Arial, Helvetica, sans-serif; + --font-stack-default-body: Franklin, Arial, Helvetica, sans-serif; --font-stack-default-headline: 'PT Sans Narrow', sans-serif; --font-family-body: var(--font-stack-default-body); diff --git a/src/static/styles/vars.css b/src/static/styles/vars.css index 0a832d0..2defb63 100644 --- a/src/static/styles/vars.css +++ b/src/static/styles/vars.css @@ -2,6 +2,7 @@ --font-stack-sans-serif: Georgia, serif; --color-neutral-00: rgb(255, 255, 255); + --color-neutral-15: rgb(230, 230, 230); --color-neutral-10: rgb(211, 211, 211); --color-neutral-20: rgb(200, 200, 200); --color-neutral-25: rgb(189, 189, 189); @@ -12,6 +13,7 @@ --color-neutral-60: rgb(102, 102, 102); --color-neutral-80: rgb(35, 35, 35); --color-neutral-90: rgb(27, 27, 27); + --color-neutral-100: rgb(0, 0, 0); /* --color-neutral-78: rgb(42, 42, 42); */ --color-peach-30: rgb(236, 169, 111);