let reformat = true; let FRACTION_MAP = { '0.25': '\u00BC', '1/4': '\u00BC', '0.5': '\u00BD', '1/2': '\u00BD', '0.75': '\u00BE', '3/4': '\u00BE', '1/3': '\u2153', '2/3': '\u2154', '1/5': '\u2155', '2/5': '\u2156', '3/5': '\u2157', '4/5': '\u2158', '1/6': '\u2159', '5/6': '\u215A', '1/8': '\u215B', '3/8': '\u215C', '5/8': '\u215D', '7/8': '\u215E', replace: function(string) { return string.replace(/\d[\/\.]\d\d?/g, function(a, b, c) { return FRACTION_MAP[a]; }) } } var isWatch = (window.outerWidth < 220); if (isWatch) document.documentElement.classList.add("watch") let ignoredTerms = [ "extra", "optional", "separate", "freshly", "use", "dry", "but", "broken", "pieces", "tbsp", "tsp", "peeled", "then", "can", "oz", "fresh", "out", "not", "sprig", "sprigs", "room", "temperature", "still", "see", "notes", "with", "beat", "together", "crust", "very", "cold", "hot", "top", "warm", "one", "note", "teaspoon", "teaspoons", "tablespoon", "tablespoons", "cup", "cups", "taste", "more", "melted", "into", "wide", "pound", "pounds", "gram", "grams", "you", "ounce", "ounces", "thinly", "sliced", "pan", "cube", "cubes", "finely", "ground", "garnish", "about", "cut", "and", "smashed", "each", "the", "medium", "large", "small", "for", "chopped", "minced", "grated", "box", "softened", "directed", "shredded", "cooked", "from", "frozen", "thawed" ] let emojiMap = { "duck": "๐Ÿ—", "beef": "๐Ÿฅฉ", "turkey": "๐Ÿ—", "chicken": "๐Ÿ—", "grape": "๐Ÿ‡", "watermelon": "๐Ÿ‰", "melon": "๐Ÿˆ", "orange": "๐ŸŠ", "lemon": "๐Ÿ‹", "banana": "๐ŸŒ", "pineapple": "๐Ÿ", "mango": "๐Ÿฅญ", "apple": "๐ŸŽ", "apple": "๐Ÿ", "pear": "๐Ÿ", "peach": "๐Ÿ‘", "cherry": "๐Ÿ’", "strawberry": "๐Ÿ“", "blueberry": "๐Ÿซ", "kiwi": "๐Ÿฅ", "garlic": "๐Ÿง„", "tomato": "๐Ÿ…", "olive": "๐Ÿซ’", "coconut": "๐Ÿฅฅ", "avocado": "๐Ÿฅ‘", "eggplant": "๐Ÿ†", "potato": "๐Ÿฅ”", "carrot": "๐Ÿฅ•", "corn": "๐ŸŒฝ", "spicy": "๐ŸŒถ๏ธ", "chard": "๐Ÿฅฌ", "peppers": "๐Ÿซ‘", "bell pepper": "๐Ÿซ‘", "cucumber": "๐Ÿฅ’", "leafy green": "๐Ÿฅฌ", "broccoli": "๐Ÿฅฆ", "onion": "๐Ÿง…", "mushroom": "๐Ÿ„", "peanut": "๐Ÿฅœ", "bean": "๐Ÿซ˜", "chestnut": "๐ŸŒฐ", "bread": "๐Ÿž", "croissant": "๐Ÿฅ", "baguette": "๐Ÿฅ–", "flatbread": "๐Ÿซ“", "pretzel": "๐Ÿฅจ", "bagel": "๐Ÿฅฏ", "pancake": "๐Ÿฅž", "waffle": "๐Ÿง‡", "cheese": "๐Ÿง€", "meat": "๐Ÿ–", "steak": "๐Ÿฅฉ", "bacon": "๐Ÿฅ“", "hamburger": "๐Ÿ”", "french fries": "๐ŸŸ", "pizza": "๐Ÿ•", "hot dog": "๐ŸŒญ", "sandwich": "๐Ÿฅช", "taco": "๐ŸŒฎ", "burrito": "๐ŸŒฏ", "tamale": "๐Ÿซ”", "stuffed flatbread": "๐Ÿฅ™", "falafel": "๐Ÿง†", "egg": "๐Ÿฅš", "cooking": "๐Ÿณ", "pan": "๐Ÿฅ˜", "pot": "๐Ÿฒ", "soup": "๐Ÿฒ", "fondue": "๐Ÿซ•", "dip": "๐Ÿซ•", "bowl": "๐Ÿฅฃ", "salad": "๐Ÿฅ—", "popcorn": "๐Ÿฟ", "butter": "๐Ÿงˆ", "salt": "๐Ÿง‚", "can": "๐Ÿฅซ", "bento box": "๐Ÿฑ", "rice cracker": "๐Ÿ˜", "rice ball": "๐Ÿ™", "rice": "๐Ÿš", "curry": "๐Ÿ›", "steaming bowl": "๐Ÿœ", "spaghetti": "๐Ÿ", "roasted sweet potato": "๐Ÿ ", "oden": "๐Ÿข", "sushi": "๐Ÿฃ", "fried shrimp": "๐Ÿค", "fish cake with swirl": "๐Ÿฅ", "moon cake": "๐Ÿฅฎ", "fruit": "๐Ÿ“", "dango": "๐Ÿก", "dumpling": "๐ŸฅŸ", "oyster": "๐Ÿฆช", "octopus": "๐Ÿ™", "fish": "๐ŸŸ", "salmon": "๐ŸŸ", "tuna": "๐ŸŸ", "soft ice cream": "๐Ÿฆ", "shaved ice": "๐Ÿง", "ice cream": "๐Ÿจ", "doughnut": "๐Ÿฉ", "cookie": "๐Ÿช", "birthday": "๐ŸŽ‚", "cake": "๐Ÿฐ", "cupcake": "๐Ÿง", "pie": "๐Ÿฅง", "chocolate": "๐Ÿซ", "candy": "๐Ÿฌ", "custard": "๐Ÿฎ", "honey": "๐Ÿฏ", "milk": "๐Ÿฅ›", "cream": "๐Ÿฅ›", "coffee": "โ˜•", "tea": "๐Ÿซ–", "tea": "๐Ÿต", "sake": "๐Ÿถ", "champagne": "๐Ÿพ", "wine glass": "๐Ÿท", "cocktail": "๐Ÿธ", "tropical drink": "๐Ÿน", "beer": "๐Ÿบ", "tumbler glass": "๐Ÿฅƒ", "cup with straw": "๐Ÿฅค", "bubble tea": "๐Ÿง‹", "beverage box": "๐Ÿงƒ", "mate": "๐Ÿง‰", "frozen": "๐ŸงŠ", "chopsticks": "๐Ÿฅข", "fork and knife": "๐Ÿด", "spoon": "๐Ÿฅ„" } let icons = { public:(''), share_android:(''), share:(``), print:(``), checklist:(''), servings:(''), timer:(''), rating:(''), info:('') } const replacements = { "teaspoon": "tsp.", "tablespoon": "Tbsp." } let lastNoun = undefined; window.addEventListener("click", (e) => { let target = e.target; if (target.classList.contains("noun")) { let els = document.querySelectorAll("#" + e.target.id); let isIngredient = target.closest(".ingredients"); for (const noun of els) { noun.classList.add("hovered"); if (noun.closest(".ingredients") != isIngredient) { noun.scrollIntoView({behavior:"smooth", block: "center"}) console.log("focusing", noun) if (noun == lastNoun) break; } } lastNoun = target; } }) window.addEventListener("mouseover", (e) => { let target = e.target; if (target.classList.contains("noun")) { let els = document.querySelectorAll("#" + e.target.id); let isIngredient = target.closest(".ingredients"); for (const noun of els) { noun.classList.add("hovered"); noun.closest(".substep")?.classList.add("hovered") noun.closest(".ingredient")?.classList.add("hovered") // if (noun.closest(".ingredients") != isIngredient) { // noun.scrollIntoView({behavior:"smooth", block: "center"}) // console.log("focusing", noun) // if (noun == lastNoun) break; // } } lastNoun = target; } }) window.addEventListener("mouseout", (e) => { let target = e.target; if (target.classList.contains("noun")) { let els = document.querySelectorAll("#" + e.target.id); for (const noun of els) { noun.classList.remove("hovered"); noun.closest(".substep")?.classList.remove("hovered") noun.closest(".ingredient")?.classList.remove("hovered") } } }) function getStringProperty(stringOrArray, prop) { if (Array.isArray(stringOrArray)) { stringOrArray = stringOrArray.pop(); } if (prop) { stringOrArray = stringOrArray[prop] } return stringOrArray?.toString(); } const m = (selector, ...args) => { var attrs = (args[0] && typeof args[0] === 'object' && !Array.isArray(args[0]) && !(args[0] instanceof HTMLElement)) ? args.shift() : {}; let classes = selector.split("."); if (classes.length > 0) selector = classes.shift(); if (classes.length) attrs.className = classes.join(" ") let id = selector.split("#"); if (id.length > 0) selector = id.shift(); if (id.length) attrs.id = id[0]; var node = document.createElement(selector.length > 0 ? selector : "div"); for (let prop in attrs) { if (attrs.hasOwnProperty(prop) && attrs[prop] != undefined) { if (prop.indexOf("data-") == 0) { let dataProp = prop.substring(5).replace(/-([a-z])/g, function(g) { return g[1].toUpperCase(); }); node.dataset[dataProp] = attrs[prop]; } else { node[prop] = attrs[prop]; } } } const append = (child) => { if (Array.isArray(child)) return child.forEach(append); if (typeof child == "string") child = document.createTextNode(child); if (child) node.appendChild(child); }; args.forEach(append); return node; }; function clean(html) { if (!html) return; let doc = new DOMParser().parseFromString(html, 'text/html'); return doc.body.textContent || ""; } function formatTime(time) { const timeRE = /(?-)?P(?:(?[.,\d]+)Y)?(?:(?[.,\d]+)M)?(?:(?[.,\d]+)W)?(?:(?[.,\d]+)D)?T(?:(?[.,\d]+)H)?(?:(?[.,\d]+)M)?(?:(?[.,\d]+)S)?/ let duration = time.match(timeRE)?.groups; if (duration) { time = []; if (duration.minutes > 60) { duration.hours = Math.floor(duration.minutes / 60); duration.minutes = duration.minutes % 60 } if (duration.hours > 0) time.push(duration.hours + "h"); if (duration.minutes > 0) time.push(duration.minutes + "m"); time = time.join(" "); } return time; } function markIngredient(e) { if (e.target.classList.contains("noun")) return; e.target.closest(".ingredient").classList.toggle("complete") } function highlightStep(e) { // console.log("e", e.target) // e.target.parent.children.forEach((i,el) => { // el.classList.toggle("complete", ) // } if (e.target.classList.contains("noun")) return; if (e.target.closest(".timer")) return; if (e.target.tagName == "A") return; e.target.closest("li").classList.toggle("complete") } const ingredientMatch = /^(?:A )?([\-\/0-9\. \u00BC-\u00BE\u2153-\u215E\u2009]*)\s(.*)/ function ingredientEl(string, terms) { if (string == "-") return m("hr"); // string = highlightTerms(string, terms) //console.log("terms", terms); let match = FRACTION_MAP.replace(clean(string)).match(ingredientMatch); // let emoji = faviconForTitle(string); if (match) { return [m("span.quantity", match[1].replace(" ", "\u202F")), " ", m("span", {innerHTML:highlightTerms(match[2], terms)})] } return [m("span.quantity", ""), m("span", {innerHTML:highlightTerms(string, terms)})]; } function highlightTerms(string, terms) { const pattern = new RegExp(`\\b((${Array.from(terms).join('|').replace("-","\\-")}))\\b`, 'gi'); return string.replace(pattern, match => { let emoji = undefined //faviconForTitle(match); return `${ emoji ? emoji + " " : ""}${match}` }); } function highlightTimes(string) { const pattern = /([0-9]+)\s*(?:to|-|โ€“)?\s*([0-9]+)?\s?(minutes?|hours?)/g; return string.replace(pattern, (match, t1, t2, tt, offset, groups) => { // console.log("match", t1, t2, tt) return `${icons.timer}${match}` }); } function dismissTimer(e) { console.log(e.target); } function playSound(loc) { var audio = new Audio(loc); // audio.play(); } function startTimer(e, t1, t2) { if (!e.startTime) { e.startTime = new Date(); let duration = t1 * 60 e.endTime = new Date(e.startTime.getTime() + duration * 1000); console.log("end", e.endTime); let countdownEl = e.querySelector(".countdown"); console.log(e, t1, t2); e.classList.add("active") let update = () => { let now = new Date(); let percent = (now - e.startTime) / 1000 / duration * 100; // console.log("perc", (now - e.startTime)/1000 , percent, duration) e.style.backgroundImage = `linear-gradient(to right, transparent ${percent}%, rgba(255,255,255,0.4) ${percent}%)` let remaining = Math.round((e.endTime - now)/1000); let expired = percent > 100; if (expired) { playSound("recipe/beep.mp4") delete e.style.backgroundImage; // clearInterval(e.interval); remaining = -remaining; e.classList.add("expired") } let countdownString = `${expired ? "-": ""}${Math.floor(remaining / 60)}m ${remaining % 60}s` countdownEl.innerText = countdownString; } e.interval = setInterval(update, 1000) update(); } else { e.interval = clearInterval(e.interval) e.classList.remove("active"); e.classList.remove("expired"); e.style.backgroundImage = ''; delete e.startTime; } } window.startTimer = startTimer; function share(e) { var viewportOffset = e.target.getBoundingClientRect(); console.log(e, viewportOffset) parent.postMessage({share:{clientX:e.clientX, clientY:e.clientY, offset:viewportOffset}}, "*"); } function faviconForTitle(title) { title = title.toLowerCase() for (let f in emojiMap) { if (title.indexOf(f) != -1) { return emojiMap[f]; } } return undefined; } function render() { try { let r = JSON.parse(window.params.body); if (r["@type"] != "Recipe") { r = Array.isArray(r) ? r : r["@graph"] r = r?.find((item)=>item["@type"]=="Recipe") } var json = r || JSON.parse(window.params.body) console.log("๐Ÿ— Rendering Recipe:", json); } catch (e) { document.body.appendChild(m("div", "Error parsing recipe", m("p", e.message), m("p", e.stack), m("pre", window.params.body) )); console.error("Data", e, {e, body: window.params.body}); return; } // console.log("head", document.head, .el, el("base", {target: "_blank"})) document.head.appendChild(m("base", {target: "_blank"})); delete document.documentElement.style.display; document.body.childNodes.forEach((c) => document.body.removeChild(c)) if (!json) return; let image = json.image; if (Array.isArray(image)) image = image.pop(); image = image?.url || image; let instructions = json.recipeInstructions || []; console.log(json.name) let title = clean(json.name); let description = json.description || json.articleBody; description = clean(description?.replace(/\\n/g, "
")) let favicon = faviconForTitle(title) || "๐Ÿด"; parent.postMessage({title:title, favicon:favicon, image:image, description:description, wakeLock:true, updateURL:true}, "*"); description = description?.split("\n").join("

") // let text = instructions.join(" "); let ingredients = json.recipeIngredient || json.ingredients; var ingredientTerms = new Set( Array.from(ingredients.join("\n").matchAll(/(\p{L}\p{M}?)+/gu)).map(m => m[0].length > 2 ? m[0].toLowerCase(): "") ); if (typeof instructions === "string") instructions = [instructions] instructions = flattenInstructions(instructions) let intructionTerms = new Set( Array.from(instructions.flat().join("\n").matchAll(/[A-Za-z\-]+/g)).map(m => m[0].length > 2 ? m[0].toLowerCase(): "") ); ignoredTerms.forEach((t) => { ingredientTerms.delete(t) }) ingredientTerms.forEach((t) => { if (!intructionTerms.has(t)) { ingredientTerms.delete(t); } }) ingredientTerms.delete(""); //console.log(ingredientTerms); // console.log("1 tablespoon".replace(/(tablespoon)/, (a) => { // return replacements[a]; // })) ingredients = ingredients.map(i => m("div.ingredient", { onclick: markIngredient }, ingredientEl(clean(i), ingredientTerms))); let step = 1; function renderInstructions(instruction, terms, i) { if (Array.isArray(instruction)) { let instructions = instruction.map((inst,i) => renderInstructions(inst, terms, i)); let className = "step"; if (instructions[0].tagName=="H3") className = "step header"; let number = undefined // m("div.number", {}, "" + step++) return m("ul", {className}, number, instructions); } let text = (instruction?.text || instruction); if (!text) return; if (text?.startsWith("= ")) return m("h3", text.substring(2)); if (text?.endsWith(":")) return m("h3", text); return m("li", { onclick: highlightStep }, i == 0 ? m("span.bullet.number" + (step > 9 ? ".big" : ""), (step++).toString()) : m("span.bullet.substep", "ยท"), m("span.substep",{innerHTML:highlightTimes(highlightTerms(FRACTION_MAP.replace(text.trim()), terms))})) } function stepsFromText(text) { let abbreviations = ["tsp", "tbsp", "oz", "lb", "lbs"]; let steps = []; let i = 0; let start = 0; let depth = 0; let lastSpace = 0; for (let i = 0; i < text.length; i++) { switch (text[i]) { case "(": depth++; break; case ")": depth--; break; case " ": lastSpace = i; break; case ".": case "!": case "?": let lastWord = text.substring(lastSpace, i).trim(); lastWord = lastWord.match(/\w+$/)?.pop().toLowerCase(); if (abbreviations.includes(lastWord)) { continue; } let end; if (text[i+1] == ")" && text[i+2] == " ") { end = i + 2; } else if (text[i+1] == " " && text[i+2] != "(" && depth == 0) { end = i + 1; } if (end) { let string = text.substring(start, end); steps.push(string.trim()); start = end; } break; case "\n": steps.push(text.substring(start, i).trim()); start = i; default: } } steps.push (text.substring(start)); return steps; } function flattenInstructions(instruction) { if (instruction.itemListElement) { return ["= " + instruction.name].concat(flattenInstructions(instruction.itemListElement)); } if (Array.isArray(instruction)) { if (Array.isArray(instruction[0])) instruction = instruction.flat(); return instruction.map(i => flattenInstructions(i)); } let text = (instruction.text || instruction.name || instruction); if (reformat) { text = stepsFromText(text); } else { text = text.match( /[^\n]+/g ); } // text = [text] // if (true) text = text.replace(/\. /g, ".

") return text; } if (instructions.length == 1) console.log("One instruction") instructions = instructions.map(inst => renderInstructions(inst, ingredientTerms)); // instructions = instructions.map(i => m("div.step", { onclick: highlightStep }, i)) let rating = json.aggregateRating; let ratingCount = rating?.ratingCount; if (ratingCount && ratingCount < 10) rating = undefined; let recipeYield = (getStringProperty(json.recipeYield)); if (!isNaN(parseInt(recipeYield?.charAt(recipeYield?.length - 1)))) recipeYield += " servings"; function imgload(e) { console.log(e, "img"); var image = document.querySelector('img'); var isLoaded = image.complete && image.naturalHeight !== 0; alert(isLoaded); } var bgImg = new Image(); bgImg.onload = function(){ let thumbnail = document.querySelector("#thumbnail"); if (!thumbnail) return; let thumbnailContainer = document.querySelector("#thumbnail-container"); thumbnail.style.backgroundImage = 'url("' + bgImg.src + '")'; let blur = 1; blur = Math.max(blur, Math.max(thumbnailContainer.offsetHeight * window.devicePixelRatio / bgImg.naturalHeight, thumbnailContainer.offsetWidth * window.devicePixelRatio / bgImg.naturalWidth)) if (blur > 1) thumbnail.style.filter = `blur(${blur}px)`; setTimeout(() => thumbnail.style.opacity = 1.0, 0); // if (window.scrollY == 0) setTimeout(() => { // const yOffset = -20; // const element = document.querySelector('.recipe-content'); // const y = element.getBoundingClientRect().top + window.pageYOffset + yOffset; // window.scrollTo({top: y, behavior: 'smooth'}); // }, 1000) }; bgImg.src = image; let originalURL = json.mainEntityOfPage?.["@id"] ?? ((json.mainEntityOfPage == true) ? false : json.mainEntityOfPage) ?? json.url; let hostname = originalURL ? new URL(originalURL).hostname.replace("www.","") : "" let qrImage = undefined // QRCodeURL(params.originalURL, {margin:0}); let publisherImage = json.publisher?.image ?.[0]?.url ?? json.publisher ?.logo ?.url; document.body.appendChild( m(".recipe", {}, image ? m("#thumbnail-container", m("#thumbnail.thumbnail.print-hide", { onclick:(e) => document.body.classList.toggle('noblur'), style: "background-image:url(" + image + ");" })) : null, m(".recipe-content", m("div.headercolumns", m("header", m("a.publisherlink", {href:originalURL, target:"_blank"}, publisherImage ? m("img.publisher", { src: publisherImage }) : (json.publisher?.name || hostname), ), m(".headerflex", m(".headerleft", m("h1", {onclick:keepAwake}, title), m(".metadata", (recipeYield) ? m("div", m("span.yield", m(".icon.servings", {innerHTML:icons.servings}), recipeYield)) : null, json.totalTime ? m(".time", m(".icon.time", {innerHTML:icons.timer}), json.totalTime ? m("span", formatTime(json.totalTime)) : undefined, // " (", // json.prepTime ? m("span", formatTime(json.prepTime), " prep") : undefined, // json.cookTime ? m("span", ", ", formatTime(json.cookTime), " cook") : undefined, // ")" ) : null, (rating) ? m("div.rating", m(".icon.rating", {innerHTML:icons.rating}), parseFloat(rating.ratingValue).toFixed(1), " ", // ratingCount ? m("span.count", ratingCount.toString()) : null ) : null, json.nutrition?.calories ? m("div", m(".icon.info", {innerHTML:icons.info}), (json.nutrition?.calories.toString().replace("calories", "Cal").replace("kcal", "Cal")) + (isNaN(json.nutrition?.calories) ? '' : ' Cal')) : null, // m("div.spacer"), ), ), m(".actions.print-hide.watch-hide", // originalURL ? m("a.action", { title:"Open original", href: originalURL, target:"_blank"}, m(".icon.public", {innerHTML:icons.public})) : null, m("a.action", { title:"Share", onclick: share}, m(".icon.share", {innerHTML:icons.share})), // m("a.action", { title:"Show steps as list", onclick: () => {reformat = !reformat; render(); return false;}}, m(".icon.checklist")), m("a.action", { title:"Print", onclick: () => {window.print(); return false;} }, m(".icon.print", {innerHTML:icons.print})), ) ), description ? m(".description", {innerHTML:description}, json.author?.name ? m("span.author", (" โ€”โ " + json.author?.name)) : null, m("p"), ) : null, ), ), m(".columns", m(isWatch ? "section.ingredients" : "section.ingredients.hanging", m("caption.ingredients-title", {onclick:(e) => {e.target.closest("section").classList.toggle("hanging")}},"Ingredients"), ingredients, // m("canvas#qr") ), m(reformat ? "section.instructions.numbered" : "section.instructions.numbered", m("caption.ingredients-title", {onclick:() => {reformat = !reformat; render(); return false;}}, reformat ? "Steps" : "Instructions", m("div.listtoggle.print-hide", {innerHTML: ''}), ), instructions) ), ), qrImage ? m("img#qr.qr.print-show", {src:qrImage, onclick:() => window.print()}) : null, ) ) } function keepAwake() { let ctx = new AudioContext(); let bufferSize = 2 * ctx.sampleRate, emptyBuffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate), output = emptyBuffer.getChannelData(0); for(let i = 0; i < bufferSize; i++) output[i] = 0; let source = ctx.createBufferSource(); source.buffer = emptyBuffer; source.loop = true; let node = ctx.createMediaStreamDestination(); source.connect(node); let audio = document.createElement("audio"); audio.style.display = "none"; document.body.appendChild(audio); audio.srcObject = node.stream; audio.play(); console.log("playing", audio) } // var path = window.script.substring(0, window.script.lastIndexOf(".")); // var cssURL = path + ".css"; // loadScript(path + '/../../js/qrious.min.js', null, "").then(() => { // console.log("qrious loaded", params.originalURL.length); // var qr = new QRious({ // element: document.getElementById("qr"), // background: 'transparent', // foreground: 'currentColor', // size: 512, // value: params.originalUrl.substring(0), // }); // }) // loadSyle(cssURL).then(render);