Skip to content

Commit

Permalink
fix: Missing Title and Metadata (#2770)
Browse files Browse the repository at this point in the history
* add document title to server spa meta

* removed conflicting useMeta

* replaced head with useMeta

* formalized metadata injection

* small injection refactor

* added tests

* added missing global tag

* fixed setting tab title for logged-in users

* simplified metadata update

* remove duplicate tag and fix for foreign users

* add metadata for shared recipes

* added default recipe image

* fixed shared URL

---------

Co-authored-by: Kuchenpirat <[email protected]>
  • Loading branch information
michael-genson and Kuchenpirat authored Dec 6, 2023
1 parent 2751e83 commit 1d1d61d
Show file tree
Hide file tree
Showing 5 changed files with 207 additions and 27 deletions.
27 changes: 14 additions & 13 deletions frontend/pages/g/_groupSlug/r/_slug/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@

<script lang="ts">
import { computed, defineComponent, ref, useAsync, useContext, useMeta, useRoute, useRouter } from "@nuxtjs/composition-api";
import { whenever } from "@vueuse/core";
import { useLoggedInState } from "~/composables/use-logged-in-state";
import { useAsyncKey } from "~/composables/use-utils";
import RecipePage from "~/components/Domain/Recipe/RecipePage/RecipePage.vue";
import { usePublicExploreApi } from "~/composables/api/api-client";
import { useRecipe } from "~/composables/recipes";
Expand All @@ -15,14 +17,13 @@ import { Recipe } from "~/lib/api/types/recipe";
export default defineComponent({
components: { RecipePage },
setup() {
const { $auth } = useContext();
const { $auth } = useContext();
const { isOwnGroup } = useLoggedInState();
const { title } = useMeta();
const route = useRoute();
const router = useRouter();
const slug = route.value.params.slug;
const { title } = useMeta();
let recipe = ref<Recipe | null>(null);
if (isOwnGroup.value) {
const { recipe: data } = useRecipe(slug);
Expand All @@ -32,28 +33,28 @@ export default defineComponent({
const api = usePublicExploreApi(groupSlug.value);
recipe = useAsync(async () => {
const { data, error } = await api.explore.recipes.getOne(slug);
if (error) {
console.error("error loading recipe -> ", error);
router.push(`/g/${groupSlug.value}`);
}
return data;
})
}, useAsyncKey())
}
title.value = recipe.value?.name || "";
whenever(
() => recipe.value,
() => {
if (recipe.value) {
title.value = recipe.value.name;
}
},
)
return {
recipe,
};
},
head() {
if (this.recipe) {
return {
title: this.recipe.name
}
}
}
head: {},
});
</script>
94 changes: 80 additions & 14 deletions mealie/routes/spa/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import json
import pathlib
from dataclasses import dataclass

from bs4 import BeautifulSoup
from fastapi import Depends, FastAPI, Response
from fastapi.encoders import jsonable_encoder
from fastapi.staticfiles import StaticFiles
Expand All @@ -16,6 +18,13 @@
from mealie.schema.user.user import PrivateUser


@dataclass
class MetaTag:
hid: str
property_name: str
content: str


class SPAStaticFiles(StaticFiles):
async def get_response(self, path: str, scope):
try:
Expand All @@ -33,10 +42,51 @@ async def get_response(self, path: str, scope):
__contents = ""


def inject_meta(contents: str, tags: list[MetaTag]) -> str:
soup = BeautifulSoup(contents, "lxml")
scraped_meta_tags = soup.find_all("meta")

tags_by_hid = {tag.hid: tag for tag in tags}
for scraped_meta_tag in scraped_meta_tags:
try:
scraped_hid = scraped_meta_tag["data-hid"]
except KeyError:
continue

if not (matched_tag := tags_by_hid.pop(scraped_hid, None)):
continue

scraped_meta_tag["property"] = matched_tag.property_name
scraped_meta_tag["content"] = matched_tag.content

# add any tags we didn't find
if soup.html and soup.html.head:
for tag in tags_by_hid.values():
html_tag = soup.new_tag(
"meta",
**{"data-n-head": "1", "data-hid": tag.hid, "property": tag.property_name, "content": tag.content},
)
soup.html.head.append(html_tag)

return str(soup)


def inject_recipe_json(contents: str, schema: dict) -> str:
schema_as_html_tag = f"""<script type="application/ld+json">{json.dumps(jsonable_encoder(schema))}</script>"""
return contents.replace("</head>", schema_as_html_tag + "\n</head>", 1)


def content_with_meta(group_slug: str, recipe: Recipe) -> str:
# Inject meta tags
recipe_url = f"{__app_settings.BASE_URL}/g/{group_slug}/r/{recipe.slug}"
image_url = f"{__app_settings.BASE_URL}/api/media/recipes/{recipe.id}/images/original.webp?version={recipe.image}"
if recipe.image:
image_url = (
f"{__app_settings.BASE_URL}/api/media/recipes/{recipe.id}/images/original.webp?version={recipe.image}"
)
else:
image_url = (
"https://raw.githubusercontent.com/hay-kot/mealie/dev/frontend/public/img/icons/android-chrome-512x512.png"
)

ingredients: list[str] = []
if recipe.settings.disable_amount: # type: ignore
Expand Down Expand Up @@ -84,20 +134,22 @@ def content_with_meta(group_slug: str, recipe: Recipe) -> str:
"nutrition": nutrition,
}

tags = [
f'<meta property="og:title" content="{recipe.name}" />',
f'<meta property="og:description" content="{recipe.description}" />',
f'<meta property="og:image" content="{image_url}" />',
f'<meta property="og:url" content="{recipe_url}" />',
'<meta name="twitter:card" content="summary_large_image" />',
f'<meta name="twitter:title" content="{recipe.name}" />',
f'<meta name="twitter:description" content="{recipe.description}" />',
f'<meta name="twitter:image" content="{image_url}" />',
f'<meta name="twitter:url" content="{recipe_url}" />',
f"""<script type="application/ld+json">{json.dumps(jsonable_encoder(as_schema_org))}</script>""",
meta_tags = [
MetaTag(hid="og:title", property_name="og:title", content=recipe.name or ""),
MetaTag(hid="og:description", property_name="og:description", content=recipe.description or ""),
MetaTag(hid="og:image", property_name="og:image", content=image_url),
MetaTag(hid="og:url", property_name="og:url", content=recipe_url),
MetaTag(hid="twitter:card", property_name="twitter:card", content="summary_large_image"),
MetaTag(hid="twitter:title", property_name="twitter:title", content=recipe.name or ""),
MetaTag(hid="twitter:description", property_name="twitter:description", content=recipe.description or ""),
MetaTag(hid="twitter:image", property_name="twitter:image", content=image_url),
MetaTag(hid="twitter:url", property_name="twitter:url", content=recipe_url),
]

return __contents.replace("</head>", "\n".join(tags) + "\n</head>", 1)
global __contents
__contents = inject_recipe_json(__contents, as_schema_org)
__contents = inject_meta(__contents, meta_tags)
return __contents


def response_404():
Expand Down Expand Up @@ -133,7 +185,7 @@ async def serve_recipe_with_meta(
user: PrivateUser | None = Depends(try_get_current_user),
session: Session = Depends(generate_session),
):
if not user:
if not user or user.group_slug != group_slug:
return serve_recipe_with_meta_public(group_slug, recipe_slug, session)

try:
Expand All @@ -149,6 +201,19 @@ async def serve_recipe_with_meta(
return response_404()


async def serve_shared_recipe_with_meta(group_slug: str, token_id: str, session: Session = Depends(generate_session)):
try:
repos = AllRepositories(session)
token_summary = repos.recipe_share_tokens.get_one(token_id)
if token_summary is None:
raise Exception("Token Not Found")

return Response(content_with_meta(group_slug, token_summary.recipe), media_type="text/html")

except Exception:
return response_404()


def mount_spa(app: FastAPI):
if not os.path.exists(__app_settings.STATIC_FILES):
return
Expand All @@ -157,4 +222,5 @@ def mount_spa(app: FastAPI):
__contents = pathlib.Path(__app_settings.STATIC_FILES).joinpath("index.html").read_text()

app.get("/g/{group_slug}/r/{recipe_slug}")(serve_recipe_with_meta)
app.get("/g/{group_slug}/shared/r/{token_id}")(serve_shared_recipe_with_meta)
app.mount("/", SPAStaticFiles(directory=__app_settings.STATIC_FILES, html=True), name="spa")
2 changes: 2 additions & 0 deletions tests/data/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@

images_test_image_2 = CWD / "images/test-image-2.png"

html_mealie_recipe = CWD / "html/mealie-recipe.html"

html_sous_vide_smoked_beef_ribs = CWD / "html/sous-vide-smoked-beef-ribs.html"

html_sous_vide_shrimp = CWD / "html/sous-vide-shrimp.html"
Expand Down
41 changes: 41 additions & 0 deletions tests/data/html/mealie-recipe.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en" data-n-head="%7B%22lang%22:%7B%221%22:%22en%22%7D%7D">
<head>
<meta data-n-head="1" data-hid="og:type" property="og:type" content="website">
<meta data-n-head="1" data-hid="og:title" property="og:title" content="Mealie">
<meta data-n-head="1" data-hid="og:site_name" property="og:site_name" content="Mealie">
<meta data-n-head="1" data-hid="og:description" property="og:description" content="Mealie is a recipe management app for your kitchen.">
<meta data-n-head="1" data-hid="og:image" property="og:image" content="https://raw.githubusercontent.com/hay-kot/mealie/dev/frontend/public/img/icons/android-chrome-512x512.png">
<meta data-n-head="1" charset="utf-8">
<meta data-n-head="1" name="viewport" content="width=device-width,initial-scale=1">
<meta data-n-head="1" data-hid="description" name="description" content="Mealie is a recipe management app for your kitchen.">
<meta data-n-head="1" data-hid="charset" charset="utf-8">
<meta data-n-head="1" data-hid="mobile-web-app-capable" name="mobile-web-app-capable" content="yes">
<meta data-n-head="1" data-hid="apple-mobile-web-app-title" name="apple-mobile-web-app-title" content="Mealie">
<meta data-n-head="1" data-hid="theme-color" name="theme-color" content="#E58325">
<title>Mealie</title>
<link data-n-head="1" data-hid="favicon" rel="icon" type="image/x-icon" href="/favicon.ico" data-n-head="ssr">
<link data-n-head="1" data-hid="shortcut icon" rel="shortcut icon" type="image/png" href="/icons/icon-x64.png" data-n-head="ssr">
<link data-n-head="1" data-hid="apple-touch-icon" rel="apple-touch-icon" type="image/png" href="/icons/apple-touch-icon.png" data-n-head="ssr">
<link data-n-head="1" data-hid="mask-icon" rel="mask-icon" href="/icons/safari-pinned-tab.svg" data-n-head="ssr">
<link data-n-head="1" rel="shortcut icon" href="/icons/android-chrome-192x192.png">
<link data-n-head="1" rel="apple-touch-icon" href="/icons/android-chrome-maskable-512x512.png" sizes="512x512">
<link data-n-head="1" rel="manifest" href="/_nuxt/manifest.260e8103.json" data-hid="manifest">
<base href="/">
<link rel="preload" href="/_nuxt/4134a9b.js" as="script">
<link rel="preload" href="/_nuxt/caa94a4.js" as="script">
<link rel="preload" href="/_nuxt/90b93a8.js" as="script">
<link rel="preload" href="/_nuxt/9da1d16.js" as="script">
</head>
<body>
<div id="__nuxt">
<style>#nuxt-loading{background:#fff;visibility:hidden;opacity:0;position:absolute;left:0;right:0;top:0;bottom:0;display:flex;justify-content:center;align-items:center;flex-direction:column;animation:nuxtLoadingIn 10s ease;-webkit-animation:nuxtLoadingIn 10s ease;animation-fill-mode:forwards;overflow:hidden}@keyframes nuxtLoadingIn{0%{visibility:hidden;opacity:0}20%{visibility:visible;opacity:0}100%{visibility:visible;opacity:1}}@-webkit-keyframes nuxtLoadingIn{0%{visibility:hidden;opacity:0}20%{visibility:visible;opacity:0}100%{visibility:visible;opacity:1}}#nuxt-loading>div,#nuxt-loading>div:after{border-radius:50%;width:5rem;height:5rem}#nuxt-loading>div{font-size:10px;position:relative;text-indent:-9999em;border:.5rem solid #f5f5f5;border-left:.5rem solid #000;-webkit-transform:translateZ(0);-ms-transform:translateZ(0);transform:translateZ(0);-webkit-animation:nuxtLoading 1.1s infinite linear;animation:nuxtLoading 1.1s infinite linear}#nuxt-loading.error>div{border-left:.5rem solid #ff4500;animation-duration:5s}@-webkit-keyframes nuxtLoading{0%{-webkit-transform:rotate(0);transform:rotate(0)}100%{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}@keyframes nuxtLoading{0%{-webkit-transform:rotate(0);transform:rotate(0)}100%{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}</style>
<script>window.addEventListener("error",function(){var e=document.getElementById("nuxt-loading");e&&(e.className+=" error")})</script>
<div id="nuxt-loading" aria-live="polite" role="status">
<div>Loading...</div>
</div>
</div>
<script>window.__NUXT__=function(r,n,a,s,e,c,o){return{config:{GLOBAL_MIDDLEWARE:null,SUB_PATH:"",axios:{browserBaseURL:""},useDark:!1,themes:{dark:{primary:r,accent:n,secondary:a,success:s,info:e,warning:c,error:o,background:"#1E1E1E"},light:{primary:r,accent:n,secondary:a,success:s,info:e,warning:c,error:o}},_app:{basePath:"/",assetsPath:"/_nuxt/",cdnURL:null}}}}("#E58325","#007A99","#973542","#43A047","#1976d2","#FF6D00","#EF5350")</script>
<script src="/_nuxt/4134a9b.js"></script><script src="/_nuxt/caa94a4.js"></script><script src="/_nuxt/90b93a8.js"></script><script src="/_nuxt/9da1d16.js"></script>
</body>
</html>
70 changes: 70 additions & 0 deletions tests/integration_tests/test_spa.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
from bs4 import BeautifulSoup

from mealie.routes.spa import MetaTag, inject_meta, inject_recipe_json
from tests import data as test_data
from tests.utils.factories import random_string


def test_spa_metadata_injection():
fp = test_data.html_mealie_recipe
with open(fp) as f:
soup = BeautifulSoup(f, "lxml")
assert soup.html and soup.html.head

tags = soup.find_all("meta")
assert tags

title_tag = None
for tag in tags:
if tag.get("data-hid") == "og:title":
title_tag = tag
break

assert title_tag and title_tag["content"]

new_title_tag = MetaTag(hid="og:title", property_name="og:title", content=random_string())
new_arbitrary_tag = MetaTag(hid=random_string(), property_name=random_string(), content=random_string())
new_html = inject_meta(str(soup), [new_title_tag, new_arbitrary_tag])

# verify changes were injected
soup = BeautifulSoup(new_html, "lxml")
assert soup.html and soup.html.head

tags = soup.find_all("meta")
assert tags

title_tag = None
for tag in tags:
if tag.get("data-hid") == "og:title":
title_tag = tag
break

assert title_tag and title_tag["content"] == new_title_tag.content

arbitrary_tag = None
for tag in tags:
if tag.get("data-hid") == new_arbitrary_tag.hid:
arbitrary_tag = tag
break

assert arbitrary_tag and arbitrary_tag["content"] == new_arbitrary_tag.content


def test_spa_recipe_json_injection():
recipe_name = random_string()
schema = {
"@context": "https://schema.org",
"@type": "Recipe",
"name": recipe_name,
}

fp = test_data.html_mealie_recipe
with open(fp) as f:
soup = BeautifulSoup(f, "lxml")
assert "https://schema.org" not in str(soup)

html = inject_recipe_json(str(soup), schema)

assert "@context" in html
assert "https://schema.org" in html
assert recipe_name in html

0 comments on commit 1d1d61d

Please sign in to comment.