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,'
}
- 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})),
)