mirror of
https://github.com/arfct/itty-bitty.git
synced 2026-03-11 08:54:33 +00:00
Add microdata parser
This commit is contained in:
parent
b8d5440e9e
commit
59b690be6c
5 changed files with 276 additions and 52 deletions
|
|
@ -172,6 +172,17 @@ function parseBittyURL(url) {
|
||||||
return {path, hashTitle, hashData}
|
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) {
|
async function testCompression(rawData) {
|
||||||
console.group("Testing compression")
|
console.group("Testing compression")
|
||||||
let t1 = performance.now()
|
let t1 = performance.now()
|
||||||
|
|
@ -442,7 +453,11 @@ async function hashString(string, base = 36) {
|
||||||
|
|
||||||
|
|
||||||
function dataToBase64(data) {
|
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) {
|
function dataToBase64FR(data) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
body {
|
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;
|
margin:0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -214,3 +214,38 @@ body:not(.loading) #loader {
|
||||||
90% {box-shadow:var(--ld18),var(--ld2), var(--ld6),var(--ld10),var(--ld14)}
|
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)}
|
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;
|
||||||
|
}
|
||||||
|
|
@ -61,12 +61,7 @@
|
||||||
document.getElementById("favicon").href = 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em"><text y=".9em">'+ favicon + '</text></svg>'
|
document.getElementById("favicon").href = 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em"><text y=".9em">'+ favicon + '</text></svg>'
|
||||||
}
|
}
|
||||||
|
|
||||||
window.el = function (tagName, attrs, ...children) {
|
window.el = bitty.el;
|
||||||
let l = document.createElement(tagName);
|
|
||||||
Object.entries(attrs).forEach(([k,v]) => l[k] = v);
|
|
||||||
children.forEach((c) => l.appendChild(c));
|
|
||||||
return l;
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderers = {
|
const renderers = {
|
||||||
"application/ld+json": {script:"/render/recipe.html"},
|
"application/ld+json": {script:"/render/recipe.html"},
|
||||||
|
|
@ -85,8 +80,7 @@
|
||||||
|
|
||||||
function share(info) { // {title, text, url}
|
function share(info) { // {title, text, url}
|
||||||
if (!info.url) info = {title:document.title, text:document.title, url:location.href};
|
if (!info.url) info = {title:document.title, text:document.title, url:location.href};
|
||||||
console.log("Share", info);
|
|
||||||
|
|
||||||
if (navigator.share) {
|
if (navigator.share) {
|
||||||
navigator.share(info)
|
navigator.share(info)
|
||||||
.then(() => { console.log('Shared!');})
|
.then(() => { console.log('Shared!');})
|
||||||
|
|
@ -196,11 +190,26 @@
|
||||||
renderContent();
|
renderContent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (e.data.error) {
|
||||||
|
showError(e.data.error)
|
||||||
|
}
|
||||||
if (e.data.setStorage) document.localStorage.setItem(contentHash, e.data.set);
|
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)
|
if (e.data.getStorage) document.getElementById("iframe").postMessage(document.localStorage.getItem(contentHash), e.origin)
|
||||||
}, false);
|
}, 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() {
|
async function renderContent() {
|
||||||
|
|
||||||
showLoader(true)
|
showLoader(true)
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,199 @@
|
||||||
// let title = params.title
|
|
||||||
|
|
||||||
|
|
||||||
parent.postMessage({title:"Parsing Content..."}, "*");
|
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 parser = new DOMParser();
|
||||||
const doc = parser.parseFromString(params.body, "text/html");
|
const doc = parser.parseFromString(params.body, "text/html");
|
||||||
|
console.log("🛠️ Parsing Document ", doc)
|
||||||
|
|
||||||
|
let findLDJsonRecipe = (document) => {
|
||||||
let ldjson = doc.querySelector('script[type="application/ld+json"]')
|
let lds = Array.from(document.querySelectorAll('script[type="application/ld+json"]'))
|
||||||
if (ldjson) {
|
.map(e => e.innerText)
|
||||||
cleanRecipe(ldjson)
|
.sort((a,b) => a.length - b.length)
|
||||||
} else {
|
|
||||||
alert("No recipe found")
|
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")
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
if (!r) continue;
|
||||||
|
|
||||||
delete json.review;
|
delete r.review;
|
||||||
delete json.video;
|
delete r.video;
|
||||||
|
if (!r.url) r.url = location.href;
|
||||||
let f = new FileReader();
|
|
||||||
f.onload = function(e) {
|
console.log("Found Recipe:", r)
|
||||||
parent.postMessage({replaceURL:e.target.result, compressURL:"true"}, "*");
|
return r;
|
||||||
};
|
|
||||||
f.readAsDataURL(new Blob([JSON.stringify(json)],{type : 'application/ld+json;charset=utf-8'}));
|
|
||||||
|
|
||||||
} catch (e) {
|
|
||||||
console.debug("Data", e, {ldjson});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -27,7 +27,7 @@ var isWatch = (window.outerWidth < 220);
|
||||||
if (isWatch) document.documentElement.classList.add("watch")
|
if (isWatch) document.documentElement.classList.add("watch")
|
||||||
|
|
||||||
let ignoredTerms = [
|
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"
|
"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 = {
|
let emojiMap = {
|
||||||
|
|
@ -61,6 +61,8 @@ let emojiMap = {
|
||||||
"carrot": "🥕",
|
"carrot": "🥕",
|
||||||
"corn": "🌽",
|
"corn": "🌽",
|
||||||
"spicy": "🌶️",
|
"spicy": "🌶️",
|
||||||
|
"chard": "🥬",
|
||||||
|
"peppers": "🫑",
|
||||||
"bell pepper": "🫑",
|
"bell pepper": "🫑",
|
||||||
"cucumber": "🥒",
|
"cucumber": "🥒",
|
||||||
"leafy green": "🥬",
|
"leafy green": "🥬",
|
||||||
|
|
@ -424,12 +426,16 @@ function faviconForTitle(title) {
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
try {
|
try {
|
||||||
let data = JSON.parse(window.params.body);
|
let r = JSON.parse(window.params.body);
|
||||||
var json = data; //JSON.parse(data);
|
|
||||||
if (!json["@type"]?.includes("Recipe")) {
|
if (r["@type"] != "Recipe") {
|
||||||
json = (json["@graph"] ?? json).find((item) => item["@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) {
|
} catch (e) {
|
||||||
|
|
||||||
document.body.appendChild(m("div", "Error parsing recipe", m("p", e.message), m("p", e.stack), m("pre", window.params.body) ));
|
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;
|
if (!json) return;
|
||||||
let image = json.image;
|
let image = json.image;
|
||||||
if (Array.isArray(image)) image = image.shift();
|
if (Array.isArray(image)) image = image.pop();
|
||||||
image = image?.url || image;
|
image = image?.url || image;
|
||||||
let instructions = json.recipeInstructions;
|
let instructions = json.recipeInstructions || [];
|
||||||
|
console.log(json.name)
|
||||||
let title = clean(json.name);
|
let title = clean(json.name);
|
||||||
let description = json.description || json.articleBody;
|
let description = json.description || json.articleBody;
|
||||||
description = clean(description?.replace(/\\n/g, "<br>"))
|
description = clean(description?.replace(/\\n/g, "<br>"))
|
||||||
|
|
@ -460,6 +467,7 @@ function render() {
|
||||||
var ingredientTerms = new Set(
|
var ingredientTerms = new Set(
|
||||||
Array.from(ingredients.join("\n").matchAll(/(\p{L}\p{M}?)+/gu)).map(m => m[0].length > 2 ? m[0].toLowerCase(): "")
|
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]
|
if (typeof instructions === "string") instructions = [instructions]
|
||||||
instructions = flattenInstructions(instructions)
|
instructions = flattenInstructions(instructions)
|
||||||
|
|
||||||
|
|
@ -628,7 +636,7 @@ function render() {
|
||||||
m("div.headercolumns",
|
m("div.headercolumns",
|
||||||
m("header",
|
m("header",
|
||||||
m("a.publisherlink", {href:originalURL, target:"_blank"},
|
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(".headerflex",
|
||||||
m(".headerleft",
|
m(".headerleft",
|
||||||
|
|
@ -659,7 +667,7 @@ function render() {
|
||||||
),
|
),
|
||||||
m(".actions.print-hide.watch-hide",
|
m(".actions.print-hide.watch-hide",
|
||||||
originalURL ? m("a.action", { title:"Open original", href: originalURL, target:"_blank"}, m(".icon.public", {innerHTML:icons.public})) : null,
|
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:"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})),
|
m("a.action", { title:"Print", onclick: () => {window.print(); return false;} }, m(".icon.print", {innerHTML:icons.print})),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue