diff --git a/docs/bitty.js b/docs/bitty.js index 7f61cdb..72fdfc0 100644 --- a/docs/bitty.js +++ b/docs/bitty.js @@ -172,6 +172,17 @@ function parseBittyURL(url) { return {path, hashTitle, hashData} } +async function testDecode(rawData) { + console.group("Testing decode") + let t1 = performance.now() + let gz = dataToBase64TD(rawData) + let t2 = performance.now() + console.log("textdecode", gz.length, Math.round((t2 - t1)*1000)); + let xz = await dataToBase64FR(rawData); + let t3 = performance.now() + console.log("reader", xz.length, Math.round((t3 - t2)*1000)); +} + async function testCompression(rawData) { console.group("Testing compression") let t1 = performance.now() @@ -442,7 +453,11 @@ async function hashString(string, base = 36) { function dataToBase64(data) { - return btoa(String.fromCharCode(...new Uint8Array(data))); + return btoa(String.fromCharCode.apply(null, new Uint8Array(data))); +} + +function dataToBase64TD(data) { + return btoa(new TextDecoder('utf8').decode(data)); } function dataToBase64FR(data) { diff --git a/docs/index.css b/docs/index.css index 6c796de..e7ac983 100644 --- a/docs/index.css +++ b/docs/index.css @@ -1,5 +1,5 @@ body { - font-family: sans-serif; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; margin:0; } @@ -214,3 +214,38 @@ body:not(.loading) #loader { 90% {box-shadow:var(--ld18),var(--ld2), var(--ld6),var(--ld10),var(--ld14)} 95% {box-shadow:var(--ld19),var(--ld3), var(--ld7),var(--ld11),var(--ld15)} } + + +dialog { + border: 2px solid white; + border-radius: 4px; + box-shadow: 0 0 0 2px black, inset 0 0 0 3px black; + min-width: 320px; + padding: 1.2em; +} +dialog, dialog button {font-size: 18px;font-weight: 600;font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";} + +dialog::backdrop { + background: repeating-conic-gradient(rgba(128,128,128,.5) 0% 25%, transparent 0% 50%) 50% / 4px 4px; +} + + +dialog button { + border: 1px solid white; + border-radius: 9px; + box-shadow: inset 0 0 0 2px black; + min-width: 4em; + min-height: 1.8em; + background-color: white; + margin-left:1em; + padding: 0 0.8em; +} +dialog button:last-child { + box-shadow: 0 0 0 4px black, inset 0 0 0 2px black; +} + +dialog form { + text-align: right; + margin-top: 1em; + font-size: 18px; +} \ No newline at end of file diff --git a/docs/index.js b/docs/index.js index f0e871b..a595bc1 100644 --- a/docs/index.js +++ b/docs/index.js @@ -61,12 +61,7 @@ document.getElementById("favicon").href = 'data:image/svg+xml,'+ favicon + '' } - window.el = function (tagName, attrs, ...children) { - let l = document.createElement(tagName); - Object.entries(attrs).forEach(([k,v]) => l[k] = v); - children.forEach((c) => l.appendChild(c)); - return l; - } + window.el = bitty.el; const renderers = { "application/ld+json": {script:"/render/recipe.html"}, @@ -85,8 +80,7 @@ function share(info) { // {title, text, url} if (!info.url) info = {title:document.title, text:document.title, url:location.href}; - console.log("Share", info); - + if (navigator.share) { navigator.share(info) .then(() => { console.log('Shared!');}) @@ -196,11 +190,26 @@ renderContent(); } } - + if (e.data.error) { + showError(e.data.error) + } if (e.data.setStorage) document.localStorage.setItem(contentHash, e.data.set); if (e.data.getStorage) document.getElementById("iframe").postMessage(document.localStorage.getItem(contentHash), e.origin) }, false); + + function showError(error) { + console.warn("🛑", error) + let dialog = el("dialog", + el("div", {}, error), + el("form", {method: "dialog"}, + + el("button", {onclick:history.back.bind(history)}, "Learn More"), + el("button", {onclick:history.back.bind(history)}, "Go Back"))); + document.body.appendChild(dialog) + dialog.showModal(); + } + async function renderContent() { showLoader(true) diff --git a/docs/render/parse.js b/docs/render/parse.js index 48edd1f..2792041 100644 --- a/docs/render/parse.js +++ b/docs/render/parse.js @@ -1,42 +1,199 @@ -// let title = params.title - - parent.postMessage({title:"Parsing Content..."}, "*"); -document.body.appendChild(document.createTextNode("• • •")) +let lca = (a,b) => { + return a.parents().has(b).first(); +} const parser = new DOMParser(); const doc = parser.parseFromString(params.body, "text/html"); +console.log("🛠️ Parsing Document ", doc) - -let ldjson = doc.querySelector('script[type="application/ld+json"]') -if (ldjson) { - cleanRecipe(ldjson) -} else { - alert("No recipe found") -} - - - -function cleanRecipe(ldjson) { - - try { - var json = JSON.parse(ldjson.innerText); //JSON.parse(data); - if (json["@type"] != "Recipe") { - json = (json["@graph"] ?? json).find((item) => item["@type"] == "Recipe") +let findLDJsonRecipe = (document) => { + let lds = Array.from(document.querySelectorAll('script[type="application/ld+json"]')) + .map(e => e.innerText) + .sort((a,b) => a.length - b.length) + + for (const ld of lds) { + let r = JSON.parse(ld); + if (r["@type"] != "Recipe") { + r = Array.isArray(r) ? r : r["@graph"] + r = r?.find((item)=>item["@type"]=="Recipe") } + if (!r) continue; - delete json.review; - delete json.video; - - let f = new FileReader(); - f.onload = function(e) { - parent.postMessage({replaceURL:e.target.result, compressURL:"true"}, "*"); - }; - f.readAsDataURL(new Blob([JSON.stringify(json)],{type : 'application/ld+json;charset=utf-8'})); - - } catch (e) { - console.debug("Data", e, {ldjson}); + delete r.review; + delete r.video; + if (!r.url) r.url = location.href; + + console.log("Found Recipe:", r) + return r; } } +function findMicrodataRecipe(doc) { + return parseMicrodata(doc).find((item)=>item["@type"]=="Recipe"); +} + +let scrapeRecipe = async document => { + + // Get title + let recipe = {}; + recipe.name = document.title + + // Get opengraph metadata + let title = document.evaluate('//meta[@property="og:title"]/@content', document, null, XPathResult.STRING_TYPE)?.stringValue; + if (title) recipe.name = title + + let siteName = document.evaluate('//meta[@property="og:site_name"]/@content', document, null, XPathResult.STRING_TYPE)?.stringValue; + if (siteName) recipe.publisher = {name:siteName} + + recipe.image = document.evaluate('//meta[@property="og:image"]/@content', document, null, XPathResult.STRING_TYPE)?.stringValue; + + // TODO: get siteLogo + + // Get microformat metadata + // let microName = document.evaluate('//*[@itemprop="name"]/@content', document, null, XPathResult.STRING_TYPE)?.stringValue; + // if (microName) recipe.name = microName; + + // recipe.recipeYield = document.evaluate('//*[@itemprop="recipeYield"]/text()', document, null, XPathResult.STRING_TYPE)?.stringValue; + // recipe.totalTime = document.evaluate('//*[@itemprop="totalTime"]/text()', document, null, XPathResult.STRING_TYPE)?.stringValue; + + // let ingredients = [] + // var xpath = `//*[@itemprop="recipeIngredient"]`; + // const match = document.evaluate(xpath, document, null, XPathResult.ANY_TYPE, null); + // let node = null; + // while (node = match.iterateNext()) { + // ingredients.push(node.innerText) + // } + // if (ingredients.length) recipe.recipeIngredient = ingredients; + + // { + // var xpath = `//*[@itemprop="recipeInstructions"]`; + // const match = document.evaluate(xpath, document, null, XPathResult.ANY_TYPE, null); + // let node = null; + // let instructions = []; + // while (node = match.iterateNext()) { + // console.warn("match", node.innerText) + // instructions.push(node.innerText); + // } + + // if (instructions.length == 1) instructions = instructions[0].trim().split("\n").filter((a)=>a.length > 1) + // recipe.recipeInstructions = instructions; + // } + + + + // Scrape Yield + if (!recipe.recipeYield) { + for(let text in ["yield", "makes", "serv"]) { + var xpath = `//div[starts-with(text(), '${text}')]`; + const match = document.evaluate(xpath, document, null, XPathResult.ANY_TYPE, null); + let node = null; + while (node = match.iterateNext()) { + recipe.recipeYield = node.innerText + } + } + } + + // Scrape ingredients + if (!recipe.recipeIngredient) { + var xpath = "//div[text()='Ingredients']"; + const match = document.evaluate(xpath, document, null, XPathResult.ANY_TYPE, null); + let node = null; + while (node = match.iterateNext()) { + console.warn("match", node.parentNode.innerText.split("\n")) + recipe.recipeIngredient = node.parentNode.innerText.trim().split("\n") + } + } + + // Scrape instructions + if (!recipe.recipeInstructions) { + var xpath = "//div[text()='Instructions']"; + const match = document.evaluate(xpath, document, null, XPathResult.ANY_TYPE, null); + let node = null; + while (node = match.iterateNext()) { + console.warn("Instructions", node.parentNode.innerText.split("\n")) + recipe.recipeInstructions = node.parentNode.innerText.trim().split("\n").filter((a)=>a.length > 1) + } + } + + console.warn("Extracted", JSON.stringify(recipe,undefined,2)) + + // return undefined + return recipe; +} + + + +let recipe = findLDJsonRecipe(doc) || findMicrodataRecipe(doc) || await scrapeRecipe(doc); +if (recipe) { + + let url = doc.evaluate('//meta[@property="og:url"]/@content', doc, null, XPathResult.STRING_TYPE)?.stringValue; + if (url) recipe.mainEntityOfPage = url + + let f = new FileReader(); + f.onload = function(e) { + parent.postMessage({replaceURL:e.target.result, compressURL:"true"}, "*"); + }; + f.readAsDataURL(new Blob([JSON.stringify(recipe)],{type : 'application/ld+json;charset=utf-8'})); +} else { + parent.postMessage({title:"No Recipe Found...", error:"No recipe was found on this page."}, "*"); +} + + + + +function parseMicrodata(doc) { + let parsedElements = [] + const microdata = []; + + let sanitize = (input) => input?.replace(/\s/gi, ' ').trim(); + + function addValue(obj, key, value) { + if (Array.isArray(obj[key])) { + obj[key].push(value); + } else if (obj[key]) { + obj[key] = [obj[key], value]; + } else { + obj[key] = value; + } + } + + function parseItem(elem) { + const data = { } + data["@type"] = elem.getAttribute('itemtype')?.split("/").pop(); + if (elem.getAttribute('itemid')) data["@id"] = elem.getAttribute('itemid'); + traverseItem(elem, data); + parsedElements.push(elem); + return data; + } + + function traverseItem(item, data) { + for (const child of item.children) { + if (!child.hasAttribute('itemprop')) { + traverseItem(child, data); + continue; + } + + const itemProps = child.getAttribute('itemprop').split(' '); + if (child.hasAttribute('itemscope')) { + const childData = parseItem(child) + itemProps.forEach(key => addValue(data, key, childData)); + } else { + itemProps.forEach(key => { + let value = key === 'url' ? child.href : sanitize(child.content || child.textContent || child.src); + addValue(data, key, value) + }); + traverseItem(child, data); + } + } + } + + doc.querySelectorAll("[itemscope]").forEach(function(elem, i) { + if (!parsedElements.includes(elem)) { + microdata.push(parseItem(elem)); + } + }); + console.log("micro", doc, microdata) + return microdata; +} \ No newline at end of file diff --git a/docs/render/recipe.js b/docs/render/recipe.js index 847b9a2..2903d32 100644 --- a/docs/render/recipe.js +++ b/docs/render/recipe.js @@ -27,7 +27,7 @@ var isWatch = (window.outerWidth < 220); if (isWatch) document.documentElement.classList.add("watch") let ignoredTerms = [ - "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", + "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 = { @@ -61,6 +61,8 @@ let emojiMap = { "carrot": "🥕", "corn": "🌽", "spicy": "🌶️", + "chard": "🥬", + "peppers": "🫑", "bell pepper": "🫑", "cucumber": "🥒", "leafy green": "🥬", @@ -424,12 +426,16 @@ function faviconForTitle(title) { function render() { try { - let data = JSON.parse(window.params.body); - var json = data; //JSON.parse(data); - if (!json["@type"]?.includes("Recipe")) { - json = (json["@graph"] ?? json).find((item) => item["@type"] == "Recipe") + 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") } - console.log("🍗 Rendering Recipe:", json); + + var json = r || JSON.parse(window.params.body) + console.log("🍗 Rendering Recipe:", json, JSON.parse(window.params.body)); + } catch (e) { document.body.appendChild(m("div", "Error parsing recipe", m("p", e.message), m("p", e.stack), m("pre", window.params.body) )); @@ -445,9 +451,10 @@ function render() { if (!json) return; let image = json.image; - if (Array.isArray(image)) image = image.shift(); + if (Array.isArray(image)) image = image.pop(); image = image?.url || image; - let instructions = json.recipeInstructions; + 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, "
")) @@ -460,6 +467,7 @@ function render() { 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) @@ -628,7 +636,7 @@ function render() { m("div.headercolumns", m("header", m("a.publisherlink", {href:originalURL, target:"_blank"}, - publisherImage ? m("img.publisher", { src: publisherImage }) : hostname, + publisherImage ? m("img.publisher", { src: publisherImage }) : (json.publisher?.name || hostname), ), m(".headerflex", m(".headerleft", @@ -659,7 +667,7 @@ function render() { ), 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})), + navigator.share ? m("a.action", { title:"Share", onclick: share}, m(".icon.share", {innerHTML:icons.share})) : undefined, // 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})), )