Better recipe support and opengraph descriptions

This commit is contained in:
Nicholas Jitkoff 2022-01-18 23:57:49 -08:00
parent 56477e40cb
commit c874029a45
10 changed files with 355 additions and 176 deletions

33
docs/404.html Normal file
View file

@ -0,0 +1,33 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Page Not Found</title>
<style media="screen">
body { background: #ECEFF1; color: rgba(0,0,0,0.87); font-family: Roboto, Helvetica, Arial, sans-serif; margin: 0; padding: 0; }
#message { background: white; max-width: 360px; margin: 100px auto 16px; padding: 32px 24px 16px; border-radius: 3px; }
#message h3 { color: #888; font-weight: normal; font-size: 16px; margin: 16px 0 12px; }
#message h2 { color: #ffa100; font-weight: bold; font-size: 16px; margin: 0 0 8px; }
#message h1 { font-size: 22px; font-weight: 300; color: rgba(0,0,0,0.6); margin: 0 0 16px;}
#message p { line-height: 140%; margin: 16px 0 24px; font-size: 14px; }
#message a { display: block; text-align: center; background: #039be5; text-transform: uppercase; text-decoration: none; color: white; padding: 16px; border-radius: 4px; }
#message, #message a { box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24); }
#load { color: rgba(0,0,0,0.4); text-align: center; font-size: 13px; }
@media (max-width: 600px) {
body, #message { margin-top: 0; background: white; box-shadow: none; }
body { border-top: 16px solid #ffa100; }
}
</style>
</head>
<body>
<div id="message">
<h2>404</h2>
<h1>Page Not Found</h1>
<p>The specified file was not found on this website. Please check the URL for mistakes and try again.</p>
<h3>Why am I seeing this?</h3>
<p>This page was generated by the Firebase Command-Line Interface. To modify it, edit the <code>404.html</code> file in your project's configured <code>public</code> directory.</p>
</div>
</body>
</html>

View file

@ -1,8 +1,8 @@
<!DOCTYPE html><html manifest="manifest.appcache"><meta name="viewport" content="width=device-width"><meta name="description" content="itty bitty things can be conveyed in a link.">
<script src="lzma/lzma-d-min.js"></script>
<script src="data.js"></script>
<script src="index.src/index.js"></script>
<!DOCTYPE html><html xmanifest="manifest.appcache"><meta name="viewport" content="width=device-width, viewport-fit=cover" viewport-fit=cover"><meta name="description" content="itty bitty things can be conveyed in a link.">
<link id="favicon" rel=icon href='data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em"><text y=".9em">🛰</text></svg>'>
<script src="/lzma/lzma-d-min.js"></script>
<script src="/data.js"></script>
<script src="/index.src/index.js"></script>
<style type="text/css">
body{font-family:sans-serif}#iframe{border:0;position:absolute;top:0;left:0;width:100%;height:100%}#edit{font-family:monospace;font-weight:bold;color:rgba(0,0,0,0.54);position:absolute;z-index:100;position:absolute;top:.85em;right:1em;display:none}#edit:not(:hover){text-decoration:none}#warning{position:absolute;border-radius:4px;background-color:#feecc2;padding:1em;font-size:16px;width:20em;z-index:100;top:10vh;left:50vw;margin-left:-10em}#warning:empty{display:none}body.toasting #iframe,body.toasting #edit{opacity:.5;pointer-events:none}body.toasting #toast{box-sizing:border-box;background-color:#feecc2;border-radius:4px;font-size:13px;left:50%;top:10px;margin-left:-160px;padding:1em;position:absolute;max-width:320px;z-index:101}body:not(.toasting) #toast,body.toasting #warning{display:none}body:not(.download) #download{display:none}#download{background:#fafafa;width:100vw;height:100vh;position:absolute;top:0;left:0;display:flex;text-decoration:none;color:black;justify-content:center;align-items:center;flex-direction:column;font-size:14px}#dl-image{width:128px;height:128px;background-position:center;background-repeat:no-repeat;background-image:url("data:image/svg+xml,%0A%3Csvg width='128' height='128' viewBox='0 0 128 128' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cmask id='path-1-outside-1_116_2' maskUnits='userSpaceOnUse' x='27' y='15' width='74' height='98' fill='black'%3E%3Crect fill='white' x='27' y='15' width='74' height='98'/%3E%3Cpath d='M80 16H28V112H100V36L80 16Z'/%3E%3C/mask%3E%3Cpath d='M80 16H28V112H100V36L80 16Z' fill='white'/%3E%3Cpath d='M28 16V15H27V16H28ZM80 16L80.7071 15.2929L80.4142 15H80V16ZM28 112H27V113H28V112ZM100 112V113H101V112H100ZM100 36H101V35.5858L100.707 35.2929L100 36ZM28 17H80V15H28V17ZM29 112V16H27V112H29ZM100 111H28V113H100V111ZM99 36V112H101V36H99ZM100.707 35.2929L80.7071 15.2929L79.2929 16.7071L99.2929 36.7071L100.707 35.2929Z' fill='black' fill-opacity='0.15' mask='url(%23path-1-outside-1_116_2)'/%3E%3C/svg%3E%0A");padding:20px 32px;box-sizing:border-box;display:flex;justify-content:center;align-items:center;color:rgba(0,0,0,0.3);font-weight:bold}#dl-button{text-decoration:none;background:gray;color:white;padding:.5em 1em;border-radius:2em;display:none}#dl-button:hover{background:black}#dl-name{margin-bottom:2em}
</style>

View file

@ -9,10 +9,25 @@ function dismiss() {
document.body.classList.remove("toasting")
}
var validTypes = ["image/svg+xml", "application/ld+json"]
function setFavicon(favicon) {
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.onhashchange = window.onload = function() {
var validTypes = ["image/svg+xml", "application/ld+json"]
window.addEventListener("message", (e) => {
if (e.origin == 'null') {
if (e.data.title) document.title = e.data.title;
if (e.data.favicon) setFavicon(e.data.favicon);
}
}, false);
window.onhashchange = window.onload = function() {
var hash = window.location.hash.substring(1);
if (window.location.search) {
console.log("window.location.search.substring(1)", window.location.search.substring(1))
window.history.replaceState(null, null, window.location.search.substring(1) + "#" + hash)
}
if (hash.length < 3) {
location.href = "/edit";
} else {
@ -56,7 +71,6 @@ window.onhashchange = window.onload = function() {
if (validTypes.includes(type)) {
let script = '<script src="' + location.origin + '/render/recipe.js"></script>'
script = script + " ".repeat(3 - (script.length % 3))
console.log("script", script.length)
preamble = btoa(script);
} else {
console.log("unknown type, rendering as download")

View file

@ -1,2 +1,2 @@
CACHE MANIFEST
# v25
# v26

154
docs/render/recipe.css Normal file
View file

@ -0,0 +1,154 @@
* {
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
}
body {
margin: 0;
}
.thumbnail {
width: 100%;
padding-top: 56.25%;
background-color: gray;
background-size: cover;
background-position: center;
}
img.publisher {
max-width: 12em;
float: right;
margin-top: -0.5em;
}
.recipe {
max-width: 40em;
margin: auto;
line-height: 133%;
}
.columns {
display: flex;
gap: 2em;
margin-bottom: 4em;
align-content: center;
justify-content: center;
}
header {
gap: 2em;
margin-bottom: 2em;
}
h1 {
margin-top: 2em;
}
.metadata {
align: right;
display: flex;
flex-wrap: wrap;
gap: 0 1em;
margin-bottom: 1em;
line-height: 2em;
}
.metadata div:after {
content: "-";
margin-left: 1em;
}
.ingredients {
font-weight: 600;
font-size: 100%;
padding-top: 1em;
flex: 0 1 35%;
}
.instructions {
white-space: pre-wrap;
line-height: 125%;
font-size: 100%;
flex: 0 1 65%;
}
.ingredient {
padding: 0.625em 1em;
margin: 0 -1em;
line-height: 1.25em;
opacity: 0.75;
cursor: pointer;
}
.ingredient.complete,
.step.complete {
text-decoration: line-through;
opacity: 0.33;
}
.ingredient:hover {
opacity: 1.0;
background-color: rgba(128, 128, 128, 0.1)
}
.step {
padding-top: 2em;
max-width: 40em;
cursor: pointer;
}
.step:before {
content: "";
width: 100px;
float: left;
margin-top: -1em;
height: 0.5px;
background-color: currentColor;
opacity: 0.54;
}
a.action {
border: none;
background: none;
text-transform: uppercase;
text-decoration: none;
color: currentColor;
cursor: pointer;
border-radius: 1em;
line-height: 2em;
padding: 0 1em;
}
a.action:hover {
background-color: rgba(128, 128, 128, 0.1)
}
@media screen and (max-width: 480px) {
/* some CSS here */
.columns {
flex-direction: column;
}
.ingredient,
.instructions,
header {
padding: 0 1em;
}
.ingredient {
margin: 0;
}
}
@media (prefers-color-scheme: dark) {
body {
background-color: #222;
color: white;
}
}
@media print {
body {
font-size: 14px;
}
.noprint {
display: none;
}
}

View file

@ -1,14 +1,31 @@
let script = document.currentScript;
FRACTION_MAP = {
'1/2': '\u00BD', '1/4': '\u00BC', '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',
'1/2': '\u00BD',
'1/4': '\u00BC',
'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/g, function(a,b,c) {
return string.replace(/\d\/\d/g, function(a, b, c) {
return FRACTION_MAP[a];
})
}
}
const m = (selector, ...args) => {
var attrs = (args[0] && typeof args[0] === 'object' && !Array.isArray(args[0]) && !(args[0] instanceof HTMLElement)) ? args.shift() : {};
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();
@ -22,12 +39,12 @@ const m = (selector, ...args) => {
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(); });
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) => {
@ -42,11 +59,11 @@ const m = (selector, ...args) => {
function formatTime(time) {
const timeRE = /(?<sign>-)?P(?:(?<years>[.,\d]+)Y)?(?:(?<months>[.,\d]+)M)?(?:(?<weeks>[.,\d]+)W)?(?:(?<days>[.,\d]+)D)?T(?:(?<hours>[.,\d]+)H)?(?:(?<minutes>[.,\d]+)M)?(?:(?<seconds>[.,\d]+)S)?/
let duration = time.match(timeRE)?.groups;
let duration = time.match(timeRE).groups;
console.log("match", duration)
if (duration) {
time = [];
if (duration.hours > 0 ) time.push(duration.hours + "h");
if (duration.hours > 0) time.push(duration.hours + "h");
if (duration.minutes > 0) time.push(duration.minutes + "m");
time = time.join(" ");
}
@ -56,26 +73,31 @@ function formatTime(time) {
function markIngredient(e) {
e.target.classList.toggle("complete")
}
function highlightStep(e) {
// console.log("e", e.target)
// e.target.parent.children.forEach((i,el) => {
// el.classList.toggle("complete", )
// }
// console.log("e", e.target)
// e.target.parent.children.forEach((i,el) => {
// el.classList.toggle("complete", )
// }
e.target.classList.toggle("complete")
}
function render() {
let data = document.body.innerText;
let data = document.body.innerHTML;
document.body.innerText = "";
delete document.documentElement.style.display;
let json = JSON.parse(data);
if (json["@type"] != "Recipe") {
json = (json["@graph"] ?? json).find((item) => item["@type"] == "Recipe")
try {
var json = JSON.parse(data);
if (json["@type"] != "Recipe") {
json = (json["@graph"] ?? json).find((item) => item["@type"] == "Recipe")
}
console.log("Recipe", json);
} catch (e) {
console.log("Data", e, {data});
}
console.log("recipe", json)
if (!json) return;
let image = json.image;
if (Array.isArray(image)) image = image.shift();
image = image.url || image;
@ -83,161 +105,55 @@ function render() {
if (Array.isArray(instructions)) {
if (Array.isArray(instructions[0])) instructions = instructions.flat()
} else {
instructions = [{text:instructions}];
instructions = [{ text: instructions }];
}
console.log(instructions);
parent.postMessage({title:json.name, favicon:"🍳"}, "*");
document.body.appendChild(
m("article.recipe",{},
m("img.publisher", {src:json.publisher?.image[0]?.url}),
m("img.thumbnail.noprint", {src:image}),
m("header",
m("article.recipe", {},
image ? m(".thumbnail.noprint", { style: "background-image:url(" + image + ");" }) : null,
m("header",
m("img.publisher", { src: json.publisher?.image ?.[0]?.url ?? json.publisher ?.logo ?.url }),
m("h1", json.name),
m(".metadata",
json.nutrition?.calories ? m("div", (json.nutrition?.calories) + " calories") : null,
m("div", json.recipeYield),
json.totalTime ? m("div", formatTime(json.totalTime)) : undefined,
json.author?.name ? m(".author", (json.author?.name)) : null,
"\u2605".repeat(json.aggregateRating?.ratingValue) + " " + parseFloat(json.aggregateRating?.ratingValue).toFixed(1) + " (" + json.aggregateRating?.ratingCount + ")",
// m(".rating", (json.aggregateRating?.ratingValue), (json.aggregateRating?.ratingCount)),
m("button.noprint", {onclick:() => window.print()},"print"),
m("a", {href:json.mainEntityOfPage}, "LINK"),
// json.nutrition?.calories ? m("div", (json.nutrition?.calories) + (parseFloat(json.nutrition?.calories) != NaN ? " calories" : "")) : null,
json.totalTime ? m("div", formatTime(json.totalTime)) : undefined,
m("div", json.recipeYield),
json.author?.name ? m(".author", (json.author?.name)) : null,
(rating = json.aggregateRating) ? [
parseFloat(rating.ratingValue).toFixed(1),
"\u2606".repeat(1),
rating.ratingCount ? " (" + rating.ratingCount + ")" : null
].join(" ") : null,
// m(".rating", (json.aggregateRating?.ratingValue), (json.aggregateRating?.ratingCount)),
m("a.action.noprint", { onclick: () => window.print() }, "print"),
m("a.action.noprint", { href: json.mainEntityOfPage }, "link"),
),
m(".description", json.description),
),
m(".description", json.description),
),
m(".columns",
m(".ingredients",
json.recipeIngredient?.map(i => m("div.ingredient", {onclick:markIngredient}, FRACTION_MAP.replace(i)))
json.recipeIngredient?.map(i => m("div.ingredient", { onclick: markIngredient }, FRACTION_MAP.replace(i)))
),
m(".instructions",
instructions.map(i => m("div.step", {onclick:highlightStep}, i.text))
instructions.map(i => m("div.step", { onclick: highlightStep }, i.text))
)
)
)
)
document.head.appendChild(m("style", {}, `
* {
font-family:-apple-system, BlinkMacSystemFont, sans-serif;
}
var path = script.src.substring(0, script.src.lastIndexOf("."));
var cssURL = path + ".css";
img.thumbnail {
width:100%;
}
img.publisher {
max-width: 12em;
margin-bottom:1em;
}
.recipe {
max-width:40em;
margin:auto;
line-height:133%;
}
.columns {
display:flex;
gap:2em;
margin-bottom:4em;
align-content:center;
justify-content:center;
}
header {
gap:2em;
margin-bottom:2em;
}
h1 {
margin-top:2em;
}
.metadata {
align:right;
display:flex;
gap:1em;
margin-bottom:1em;
}
.metadata div:after {
content:"-";
margin-left:1em;
}
.ingredients {
font-weight:600;
font-size:100%;
padding-top:1em;
flex:0 1 35%;
}
.instructions {
white-space: pre-wrap;
line-height:125%;
font-size:100%;
flex: 0 1 65%;
}
.ingredient {
padding: 0.625em 1em;
margin:0 -1em;
line-height:1.25em;
opacity:0.75;
cursor:pointer;
}
.ingredient.complete,
.step.complete {
text-decoration:line-through;
opacity:0.33;
}
.ingredient:hover {
opacity:1.0;
background-color:rgba(128,128,128,0.1)
}
.step {
padding-top:2em;
max-width:40em;
cursor:pointer;
}
.step:before {
content:"";
width:100px;
float:left;
margin-top:-1em;
height:0.5px;
background-color:currentColor;
opacity:0.54;
}
button {
border:none;
background:none;
text-transform:uppercase;
}
@media screen and (max-width: 480px) {
/* some CSS here */
.columns {
flex-direction:column;
}
}
@media (prefers-color-scheme: dark) {
body {
background-color:#222;
color:white;
}
}
@media print {
body { font-size:14px; }
.noprint { display:none; }
}
`));
let style = m("link", { rel: "stylesheet", type: "text/css", href: cssURL })
document.head.appendChild(style);
}
window.addEventListener('load', (event) => {
render();
});
});

View file

@ -2,24 +2,23 @@
"hosting": {
"public": "docs",
"ignore": [
"firebase.json",
"**/.*",
"**/node_modules/**",
"samples/**",
"src/**",
"LICENSE",
"**.md",
"**/.git/**",
"**.sh",
"index.src/**"
"**/.*",
"**/.git/**",
"**/node_modules/**",
"firebase.json",
"index.src/**",
"LICENSE",
"samples/**",
"src/**"
],
"rewrites": [
{
"source": "**",
"destination": "/index.html"
"source": "/**/",
"function": "index"
}
],
"cleanUrls": true,
"trailingSlash": false
]
}
}
}

1
functions/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
node_modules/

39
functions/index.js Normal file
View file

@ -0,0 +1,39 @@
const functions = require("firebase-functions");
exports.index = functions.https.onRequest((request, response) => {
functions.logger.info("Hello logs!", );
const agent = request.headers["user-agent"];
let path = request.path;
if (agent.indexOf("Twitterbot") != -1 || agent.indexOf("facebookexternalhit") != -1) {
let components = path.split("/");
components.shift();
components.pop();
let title = components.shift().replace("_", " ");
let desc = components.shift().replace("_", " ");
let image = components.join("/");
console.log("components", title, desc, image)
let content = "";
if (title) content += `<title>${title}</title><meta property="og:title" content="${title}"/>`;
if (desc) content += `<meta name="description" content="${desc}"/><meta property="og:description" content="${desc}"/>`;
if (image) {
if (image.startsWith("http")) {
content += `<meta property="og:image" content="${image}"/>`;
} else {
image = decodeURIComponent(image)
console.log("image", image);
let codepoints = [];
for (const char of image) {
codepoints.push(char.codePointAt(0).toString(16));
}
content += `<link rel="apple-touch-icon" href="https://fonts.gstatic.com/s/e/notoemoji/14.0/${codepoints.join("_")}/72.png">`;
// https://fonts.gstatic.com/s/e/notoemoji/14.0/1f468_1f3fd_200d_1f91d_200d_1f468_1f3fc/72.png
// https://fonts.gstatic.com/s/e/notoemoji/14.0/1f468-1f3fd-200d-1f91d-200d-1f468-1f3fc/72.png
}
}
response.send(content);
} else {
response.redirect("/?" + path);
}
});

23
functions/package.json Normal file
View file

@ -0,0 +1,23 @@
{
"name": "functions",
"description": "Cloud Functions for Firebase",
"scripts": {
"serve": "firebase emulators:start --only functions",
"shell": "firebase functions:shell",
"start": "npm run shell",
"deploy": "firebase deploy --only functions",
"logs": "firebase functions:log"
},
"engines": {
"node": "16"
},
"main": "index.js",
"dependencies": {
"firebase-admin": "^9.8.0",
"firebase-functions": "^3.14.1"
},
"devDependencies": {
"firebase-functions-test": "^0.2.0"
},
"private": true
}