Skip to content

Commit

Permalink
Merge pull request #608 from FabienArcellier/feat/547-add-metadata-fo…
Browse files Browse the repository at this point in the history
…r-social-media-sharing

feat: add metadata for SEO and social media sharing - WF-77
  • Loading branch information
ramedina86 authored Nov 29, 2024
2 parents 35c2cd7 + 517d59b commit 4dd52d8
Show file tree
Hide file tree
Showing 4 changed files with 202 additions and 11 deletions.
84 changes: 84 additions & 0 deletions docs/framework/seo.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
---
title: "SEO"
mode: "wide"
---

Writer Framework offers you the possibility of optimizing metadata to optimize your SEO and the sharing of information on social networks.

### Configure page title

The page title is editable for web crawlers. This title is a key element for the SEO of your application. A bot will not load the app. It will see `Writer Framework` by default.

```python
writer.serve.configure_webpage_metadata(title="My amazing app")
```

If you need dynamic title,you can use a function instead of a hard coded parameter. The title will be evaluated when the Robot loads the page.

```python
def _title():
last_news = db.get_last_news()
return f"Last news: {last_news.title}"

writer.serve.configure_webpage_metadata(title=_title)
```

### Configure meta tags

http headers allow you to specify a title, a description and keywords which will be used by a search engine.

*./server_setup.py*
```python
writer.serve.configure_webpage_metadata(
title="My amazing app",
meta={
"description": "my amazing app",
"keywords": "WF, Amazing, AI App",
"author": "Amazing company"
}
)
```

You can also use a function to generate the meta tags dynamically.

```python
def _meta():
last_news = db.get_last_news()
return {
"description": f"Last news: {last_news.title}",
"keywords": f"{last_news.keywords}",
"author": "Amazing company"
}

writer.serve.configure_webpage_metadata(meta=_meta)
```

### Configure social networks

When you share a link on social networks, they will try to fetch the metadata of the page to display a preview.

```python
writer.serve.configure_webpage_metadata(
opengraph_tags= {
"og:title": "My App",
"og:description": "My amazing app",
"og:image": "https://myapp.com/logo.png",
"og:url": "https://myapp.com"
}
)

You can also use a function to generate the opengraph tags dynamically.

```python
def _opengraph_tags():
last_news = db.get_last_news()
return {
"og:title": f"Last news: {last_news.title}",
"og:description": f"{last_news.description}",
"og:image": f"{last_news.image}",
"og:url": f"https://myapp.com/news/{last_news.id}"
}

writer.serve.configure_webpage_metadata(opengraph_tags=_opengraph_tags)
```

7 changes: 3 additions & 4 deletions src/ui/index.html
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" prefix="og: https://ogp.me/ns#">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="./static/favicon.png?2" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Writer Framework</title>
<!-- {{ meta }} -->
<!-- {{ opengraph_tags }} -->
<style>
* {
box-sizing: border-box;
Expand Down Expand Up @@ -83,9 +85,6 @@
</svg>
</div>
</div>
<script type="text/javascript">
document.title = "Loading...";
</script>
<script type="module" src="./src/main.ts"></script>
</body>
</html>
4 changes: 3 additions & 1 deletion src/ui/src/renderer/ComponentRenderer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,9 @@ const isMessagePending = computed(() => wf.frontendMessageMap.value.size > 0);
watch(
() => coreRootFields.appName?.value,
(appName: string) => {
updateTitle(appName);
if (appName != "") {
updateTitle(appName);
}
},
{ immediate: true },
);
Expand Down
118 changes: 112 additions & 6 deletions src/writer/serve.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import asyncio
import html
import importlib.util
import io
import logging
import mimetypes
import os
Expand Down Expand Up @@ -50,6 +52,9 @@ class WriterState(typing.Protocol):
app_runner: AppRunner
writer_app: bool
is_server_static_mounted: bool
meta: Union[Dict[str, Any], Callable[[], Dict[str, Any]]] # meta tags for SEO
opengraph_tags: Union[Dict[str, Any], Callable[[], Dict[str, Any]]] # opengraph tags for social networks integration (facebook, discord)
title: Union[str, Callable[[], str]] # title of the page, default: "Writer Framework"

class WriterAsgi(typing.Protocol):
state: WriterState
Expand All @@ -65,7 +70,7 @@ def get_asgi_app(
enable_remote_edit: bool = False,
enable_server_setup: bool = True,
on_load: Optional[Callable] = None,
on_shutdown: Optional[Callable] = None,
on_shutdown: Optional[Callable] = None
) -> WriterFastAPI:
"""
Builds an ASGI server that can be injected into another ASGI application
Expand Down Expand Up @@ -434,17 +439,16 @@ async def stream(websocket: WebSocket):

user_app_static_path = pathlib.Path(user_app_path) / "static"
if user_app_static_path.exists():
app.mount(
"/static", StaticFiles(directory=str(user_app_static_path)), name="user_static")
app.mount("/static", StaticFiles(directory=str(user_app_static_path)), name="user_static")

user_app_extensions_path = pathlib.Path(user_app_path) / "extensions"
if user_app_extensions_path.exists():
app.mount(
"/extensions", StaticFiles(directory=str(user_app_extensions_path)), name="extensions")
app.mount("/extensions", StaticFiles(directory=str(user_app_extensions_path)), name="extensions")

server_path = pathlib.Path(__file__)
server_static_path = server_path.parent / "static"
if server_static_path.exists():
_mount_render_index_html(app, server_static_path)
_mount_server_static_path(app, server_static_path)
app.state.is_server_static_mounted = True
else:
Expand Down Expand Up @@ -556,6 +560,78 @@ async def lifespan(app: FastAPI):
async with _lifespan_invoke(writer_lifespans, app):
yield

def configure_webpage_metadata(
title: Union[str, Callable[[], str]] = "Writer Framework",
meta: Optional[Union[Dict[str, Any], Callable[[], Dict[str, Any]]]] = None,
opengraph_tags: Optional[Union[Dict[str, Any], Callable[[], Dict[str, Any]]]] = None
):
"""
Configures the page header for SEO and social networks from `server_setup` module.
>>> writer.serve.configure_webpage_metadata(
>>> title="my App",
>>> meta={
>>> "description": "my amazing app",
>>> "keywords": "WF, Amazing, AI App",
>>> "author": "Amazing company"
>>> }
>>>)
Meta will accept description, keywords, author. Other meta tags as view port won't be supported.
Settings accept functions to adapt content based on application data.
>>> def generated_title():
>>> return "My App" # load title using info from database
>>> def generated_meta_tags():
>>> {
>>> "description": "my amazing app",
>>> "keywords": "WF, Amazing, AI App",
>>> "author": "Amazing company"
>>> }
>>> writer.serve.configure_webpage_metadata(
>>> title=generated_title
>>> meta=generated_meta_tags
>>> )
OpenGraph tags are used by social networks to display information about the page. WF support them.
>>> writer.serve.configure_webpage_metadata(
>>> title=generated_title
>>> opengraph_tags= {
>>> "og:title": "My App",
>>> "og:description": "My amazing app",
>>> "og:image": "https://myapp.com/logo.png",
>>> "og:url": "https://myapp.com"
>>> }
>>> )
>>> def generated_opengraph_tags():
>>> return {
>>> "og:title": "My App",
>>> "og:description": "My amazing app",
>>> }
>>> writer.serve.configure_webpage_metadata(
>>> title=generated_title
>>> opengraph_tags= generated_opengraph_tags
>>> )
---
WF replaces the placeholders <!-- {{ meta }} -->, <!-- {{ opengraph_tags }} -->
and the <title>Writer framework<title> tag in the index.html file.
:param title: The title of the page. Default: "Writer Framework"
:param meta: A list of meta tags. Default: {}
:param opengraph_tags: A dictionary of OpenGraph tags. Default: {}
"""
app.state.title = title
app.state.meta = meta if meta is not None else {}
app.state.opengraph_tags = opengraph_tags if opengraph_tags is not None else {}


@asynccontextmanager
async def _lifespan_invoke(context: list, app: FastAPI):
Expand Down Expand Up @@ -601,13 +677,43 @@ def _mount_server_static_path(app: FastAPI, server_static_path: pathlib.Path) ->
Writer Framework routes remain priority. A developer cannot come and overload them.
"""
app.get('/')(lambda: FileResponse(server_static_path.joinpath('index.html')))
for f in wf_root_static_assets():
if f.is_file():
app.get(f"/{f.name}")(lambda: FileResponse(f))
if f.is_dir():
app.mount(f"/{f.name}", StaticFiles(directory=f), name=f"server_static_{f}")

def _mount_render_index_html(app: FastAPI, server_static_path: pathlib.Path):
"""
Serves the main page with the title that has been configured.
:param app:
:param server_static_path:
:return:
"""
def _render_index_html():
with io.open(server_static_path.joinpath('index.html'), 'r', encoding='utf-8') as f:
index_html = f.read()
if hasattr(app.state, "title"):
index_html = index_html.replace("<title>Writer Framework</title>", f"<title>{html.escape(app.state.title)}</title>")

if hasattr(app.state, "meta"):
meta = app.state.meta() if callable(app.state.meta) else app.state.meta
meta_tags = "\n".join([f'<meta name="{k}" content="{html.escape(v)}">' for k, v in meta.items()])
index_html = index_html.replace("<!-- {{ meta }} -->", meta_tags)
else:
index_html = index_html.replace("<!-- {{ meta }} -->", "")

if hasattr(app.state, "opengraph_tags"):
opengraph_tags = app.state.opengraph_tags() if callable(app.state.opengraph_tags) else app.state.opengraph_tags
opengraph_tags = "\n".join([f'<meta property="{k}" content="{html.escape(v)}">' for k, v in opengraph_tags.items()])
index_html = index_html.replace("<!-- {{ opengraph_tags }} -->", opengraph_tags)
else:
index_html = index_html.replace("<!-- {{ opengraph_tags }} -->", "")

return Response(content=index_html, media_type="text/html")

return app.get('/')(_render_index_html)

def app_runner(asgi_app: WriterFastAPI) -> AppRunner:
return asgi_app.state.app_runner
Expand Down

0 comments on commit 4dd52d8

Please sign in to comment.