fix: Missing Title and Metadata (#2770)

* 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 <24235032+Kuchenpirat@users.noreply.github.com>
This commit is contained in:
Michael Genson 2023-12-06 09:01:48 -06:00 committed by GitHub
parent 2751e8318a
commit 1d1d61df77
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 207 additions and 27 deletions

View File

@ -6,7 +6,9 @@
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, ref, useAsync, useContext, useMeta, useRoute, useRouter } from "@nuxtjs/composition-api"; 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 { useLoggedInState } from "~/composables/use-logged-in-state";
import { useAsyncKey } from "~/composables/use-utils";
import RecipePage from "~/components/Domain/Recipe/RecipePage/RecipePage.vue"; import RecipePage from "~/components/Domain/Recipe/RecipePage/RecipePage.vue";
import { usePublicExploreApi } from "~/composables/api/api-client"; import { usePublicExploreApi } from "~/composables/api/api-client";
import { useRecipe } from "~/composables/recipes"; import { useRecipe } from "~/composables/recipes";
@ -15,14 +17,13 @@ import { Recipe } from "~/lib/api/types/recipe";
export default defineComponent({ export default defineComponent({
components: { RecipePage }, components: { RecipePage },
setup() { setup() {
const { $auth } = useContext(); const { $auth } = useContext();
const { isOwnGroup } = useLoggedInState(); const { isOwnGroup } = useLoggedInState();
const { title } = useMeta();
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const slug = route.value.params.slug; const slug = route.value.params.slug;
const { title } = useMeta();
let recipe = ref<Recipe | null>(null); let recipe = ref<Recipe | null>(null);
if (isOwnGroup.value) { if (isOwnGroup.value) {
const { recipe: data } = useRecipe(slug); const { recipe: data } = useRecipe(slug);
@ -32,28 +33,28 @@ export default defineComponent({
const api = usePublicExploreApi(groupSlug.value); const api = usePublicExploreApi(groupSlug.value);
recipe = useAsync(async () => { recipe = useAsync(async () => {
const { data, error } = await api.explore.recipes.getOne(slug); const { data, error } = await api.explore.recipes.getOne(slug);
if (error) { if (error) {
console.error("error loading recipe -> ", error); console.error("error loading recipe -> ", error);
router.push(`/g/${groupSlug.value}`); router.push(`/g/${groupSlug.value}`);
} }
return data; return data;
}) }, useAsyncKey())
} }
title.value = recipe.value?.name || ""; whenever(
() => recipe.value,
() => {
if (recipe.value) {
title.value = recipe.value.name;
}
},
)
return { return {
recipe, recipe,
}; };
}, },
head() { head: {},
if (this.recipe) {
return {
title: this.recipe.name
}
}
}
}); });
</script> </script>

View File

@ -1,6 +1,8 @@
import json import json
import pathlib import pathlib
from dataclasses import dataclass
from bs4 import BeautifulSoup
from fastapi import Depends, FastAPI, Response from fastapi import Depends, FastAPI, Response
from fastapi.encoders import jsonable_encoder from fastapi.encoders import jsonable_encoder
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
@ -16,6 +18,13 @@ from mealie.schema.recipe.recipe import Recipe
from mealie.schema.user.user import PrivateUser from mealie.schema.user.user import PrivateUser
@dataclass
class MetaTag:
hid: str
property_name: str
content: str
class SPAStaticFiles(StaticFiles): class SPAStaticFiles(StaticFiles):
async def get_response(self, path: str, scope): async def get_response(self, path: str, scope):
try: try:
@ -33,10 +42,51 @@ __app_settings = get_app_settings()
__contents = "" __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: def content_with_meta(group_slug: str, recipe: Recipe) -> str:
# Inject meta tags # Inject meta tags
recipe_url = f"{__app_settings.BASE_URL}/g/{group_slug}/r/{recipe.slug}" 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] = [] ingredients: list[str] = []
if recipe.settings.disable_amount: # type: ignore if recipe.settings.disable_amount: # type: ignore
@ -84,20 +134,22 @@ def content_with_meta(group_slug: str, recipe: Recipe) -> str:
"nutrition": nutrition, "nutrition": nutrition,
} }
tags = [ meta_tags = [
f'<meta property="og:title" content="{recipe.name}" />', MetaTag(hid="og:title", property_name="og:title", content=recipe.name or ""),
f'<meta property="og:description" content="{recipe.description}" />', MetaTag(hid="og:description", property_name="og:description", content=recipe.description or ""),
f'<meta property="og:image" content="{image_url}" />', MetaTag(hid="og:image", property_name="og:image", content=image_url),
f'<meta property="og:url" content="{recipe_url}" />', MetaTag(hid="og:url", property_name="og:url", content=recipe_url),
'<meta name="twitter:card" content="summary_large_image" />', MetaTag(hid="twitter:card", property_name="twitter:card", content="summary_large_image"),
f'<meta name="twitter:title" content="{recipe.name}" />', MetaTag(hid="twitter:title", property_name="twitter:title", content=recipe.name or ""),
f'<meta name="twitter:description" content="{recipe.description}" />', MetaTag(hid="twitter:description", property_name="twitter:description", content=recipe.description or ""),
f'<meta name="twitter:image" content="{image_url}" />', MetaTag(hid="twitter:image", property_name="twitter:image", content=image_url),
f'<meta name="twitter:url" content="{recipe_url}" />', MetaTag(hid="twitter:url", property_name="twitter:url", content=recipe_url),
f"""<script type="application/ld+json">{json.dumps(jsonable_encoder(as_schema_org))}</script>""",
] ]
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(): def response_404():
@ -133,7 +185,7 @@ async def serve_recipe_with_meta(
user: PrivateUser | None = Depends(try_get_current_user), user: PrivateUser | None = Depends(try_get_current_user),
session: Session = Depends(generate_session), 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) return serve_recipe_with_meta_public(group_slug, recipe_slug, session)
try: try:
@ -149,6 +201,19 @@ async def serve_recipe_with_meta(
return response_404() 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): def mount_spa(app: FastAPI):
if not os.path.exists(__app_settings.STATIC_FILES): if not os.path.exists(__app_settings.STATIC_FILES):
return return
@ -157,4 +222,5 @@ def mount_spa(app: FastAPI):
__contents = pathlib.Path(__app_settings.STATIC_FILES).joinpath("index.html").read_text() __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}/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") app.mount("/", SPAStaticFiles(directory=__app_settings.STATIC_FILES, html=True), name="spa")

View File

@ -22,6 +22,8 @@ images_test_image_1 = CWD / "images/test-image-1.jpg"
images_test_image_2 = CWD / "images/test-image-2.png" 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_smoked_beef_ribs = CWD / "html/sous-vide-smoked-beef-ribs.html"
html_sous_vide_shrimp = CWD / "html/sous-vide-shrimp.html" html_sous_vide_shrimp = CWD / "html/sous-vide-shrimp.html"

View File

@ -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>

View File

@ -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