memi-engine 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {memi_engine-0.2.0 → memi_engine-0.3.0}/PKG-INFO +50 -7
- {memi_engine-0.2.0 → memi_engine-0.3.0}/README.md +47 -4
- {memi_engine-0.2.0 → memi_engine-0.3.0}/memi_engine/app.py +86 -26
- {memi_engine-0.2.0 → memi_engine-0.3.0}/memi_engine/config.py +35 -0
- memi_engine-0.3.0/memi_engine/seo.py +165 -0
- {memi_engine-0.2.0 → memi_engine-0.3.0}/memi_engine/templates/about.html +2 -10
- memi_engine-0.3.0/memi_engine/templates/head.html +28 -0
- {memi_engine-0.2.0 → memi_engine-0.3.0}/memi_engine/templates/index.html +2 -10
- {memi_engine-0.2.0 → memi_engine-0.3.0}/pyproject.toml +2 -2
- {memi_engine-0.2.0 → memi_engine-0.3.0}/.gitignore +0 -0
- {memi_engine-0.2.0 → memi_engine-0.3.0}/LICENSE +0 -0
- {memi_engine-0.2.0 → memi_engine-0.3.0}/memi_engine/__init__.py +0 -0
- {memi_engine-0.2.0 → memi_engine-0.3.0}/memi_engine/images.py +0 -0
- {memi_engine-0.2.0 → memi_engine-0.3.0}/memi_engine/menu.py +0 -0
- {memi_engine-0.2.0 → memi_engine-0.3.0}/memi_engine/provider.py +0 -0
- {memi_engine-0.2.0 → memi_engine-0.3.0}/memi_engine/py.typed +0 -0
- {memi_engine-0.2.0 → memi_engine-0.3.0}/memi_engine/registry.py +0 -0
- {memi_engine-0.2.0 → memi_engine-0.3.0}/memi_engine/scientific.py +0 -0
- {memi_engine-0.2.0 → memi_engine-0.3.0}/memi_engine/scientific_names.py +0 -0
- {memi_engine-0.2.0 → memi_engine-0.3.0}/memi_engine/static/css/style.css +0 -0
- {memi_engine-0.2.0 → memi_engine-0.3.0}/memi_engine/static/js/app.js +0 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: memi-engine
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Engine for building memi memory card game instances
|
|
5
|
-
Project-URL: Homepage, https://memi.
|
|
5
|
+
Project-URL: Homepage, https://memi.games
|
|
6
6
|
Project-URL: Repository, https://github.com/filias/memi-engine
|
|
7
7
|
Project-URL: Issues, https://github.com/filias/memi-engine/issues
|
|
8
8
|
Author-email: Filipa Andrade <filipa.andrade@gmail.com>
|
|
@@ -47,7 +47,7 @@ Description-Content-Type: text/markdown
|
|
|
47
47
|
> between what you see and what you know. The answer is always one tap away — the
|
|
48
48
|
> point is to reach for it yourself first.
|
|
49
49
|
|
|
50
|
-
`memi-engine` lets you build your own [memi](https://memi.
|
|
50
|
+
`memi-engine` lets you build your own [memi](https://memi.games) game — a
|
|
51
51
|
tap-to-reveal flashcard trainer — from a list of names and where to find their
|
|
52
52
|
images.
|
|
53
53
|
|
|
@@ -219,6 +219,10 @@ Passed to `create_app`. Common fields:
|
|
|
219
219
|
| `wikipedia_lang` | `"en"` | Wikipedia edition for default images / *know more* links. |
|
|
220
220
|
| `related_sites` | `[]` | Sibling games to link from the about page. |
|
|
221
221
|
| `label_*` | English strings | UI labels (for localization). |
|
|
222
|
+
| `html_lang` | `"en"` | `<html lang>` — set it for a non-English game. |
|
|
223
|
+
| `site_url` | `None` | Canonical origin; taken from the request if unset. |
|
|
224
|
+
| `description` | `""` | Home page meta description; falls back to `about_html`. |
|
|
225
|
+
| `og_image` | `None` | Link-preview image (absolute URL). |
|
|
222
226
|
|
|
223
227
|
For a non-English game, set `wikipedia_lang` so the default image lookup and the
|
|
224
228
|
*"know more"* link resolve against that language's Wikipedia (e.g. `"pt"`). It
|
|
@@ -237,6 +241,44 @@ app = create_app(config, instance_static="/path/to/static")
|
|
|
237
241
|
# served at /static/... , falling back to the engine's static files
|
|
238
242
|
```
|
|
239
243
|
|
|
244
|
+
## Search discoverability
|
|
245
|
+
|
|
246
|
+
One game is served from many URLs: `/`, a `/<slug>` landing page per category,
|
|
247
|
+
and `/about`. The category you are playing is a JavaScript variable, so nothing
|
|
248
|
+
in the markup distinguishes those pages — left alone they are one page repeated,
|
|
249
|
+
and a crawler indexes one and discards the rest.
|
|
250
|
+
|
|
251
|
+
The engine gives each of them its own metadata, derived from what the instance
|
|
252
|
+
already declares:
|
|
253
|
+
|
|
254
|
+
| Page | Title | Description | Canonical |
|
|
255
|
+
| --- | --- | --- | --- |
|
|
256
|
+
| `/` | `{title} — {subtitle}` | `description`, else the opening of `about_html` | `/` |
|
|
257
|
+
| `/<slug>` | `{category} — {title}` | `{category} — {description}` | the category's short slug |
|
|
258
|
+
| `/about` | `{label_about} — {title}` | as the home page | `/about` |
|
|
259
|
+
|
|
260
|
+
The title and description patterns are `home_title`, `category_title` and
|
|
261
|
+
`category_description` on `MemiConfig`; a category pattern can also use
|
|
262
|
+
`{count}`, the number of items in it. Defaults are punctuation only, so a
|
|
263
|
+
localized game stays in its own language without touching them.
|
|
264
|
+
|
|
265
|
+
Two more things fall out of this:
|
|
266
|
+
|
|
267
|
+
- **`/robots.txt`** opens the site, excludes `/api/` (JSON a crawler can do
|
|
268
|
+
nothing with) and names the sitemap.
|
|
269
|
+
- **`/sitemap.xml`** lists `/`, `/about` and every category — built from the
|
|
270
|
+
registry, so a new provider appears without an edit. A category whose last
|
|
271
|
+
segment is unique answers at both `/food` and `/culture-food`; only the short
|
|
272
|
+
one is listed, and the long one canonicalises to it.
|
|
273
|
+
|
|
274
|
+
Canonical and `og:` URLs need the site's real origin. Behind a reverse proxy
|
|
275
|
+
that sends `X-Forwarded-Proto` and `X-Forwarded-Host` (Caddy does by default)
|
|
276
|
+
the engine works it out per request. Set `site_url` — or `MEMI_SITE_URL` — to
|
|
277
|
+
pin it instead.
|
|
278
|
+
|
|
279
|
+
Set **`html_lang`** per instance. It defaults to `"en"`, and a Portuguese game
|
|
280
|
+
that tells Google it is English is a game shown to the wrong people.
|
|
281
|
+
|
|
240
282
|
## Deployment
|
|
241
283
|
|
|
242
284
|
The app is a standard WSGI Flask app. For production, install the `server`
|
|
@@ -253,9 +295,10 @@ players report a bad card).
|
|
|
253
295
|
|
|
254
296
|
## Live examples
|
|
255
297
|
|
|
256
|
-
Real games built on this engine
|
|
257
|
-
[memi
|
|
258
|
-
[memi
|
|
298
|
+
Real games built on this engine, all linked from
|
|
299
|
+
[memi.games](https://memi.games): [memi](https://world.memi.games) ·
|
|
300
|
+
[memi portugal](https://pt.memi.games) · [memi lisboa](https://lx.memi.games) ·
|
|
301
|
+
[memi slovensko](https://sk.memi.games) · [memi US](https://us.memi.games).
|
|
259
302
|
|
|
260
303
|
## Development
|
|
261
304
|
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
> between what you see and what you know. The answer is always one tap away — the
|
|
15
15
|
> point is to reach for it yourself first.
|
|
16
16
|
|
|
17
|
-
`memi-engine` lets you build your own [memi](https://memi.
|
|
17
|
+
`memi-engine` lets you build your own [memi](https://memi.games) game — a
|
|
18
18
|
tap-to-reveal flashcard trainer — from a list of names and where to find their
|
|
19
19
|
images.
|
|
20
20
|
|
|
@@ -186,6 +186,10 @@ Passed to `create_app`. Common fields:
|
|
|
186
186
|
| `wikipedia_lang` | `"en"` | Wikipedia edition for default images / *know more* links. |
|
|
187
187
|
| `related_sites` | `[]` | Sibling games to link from the about page. |
|
|
188
188
|
| `label_*` | English strings | UI labels (for localization). |
|
|
189
|
+
| `html_lang` | `"en"` | `<html lang>` — set it for a non-English game. |
|
|
190
|
+
| `site_url` | `None` | Canonical origin; taken from the request if unset. |
|
|
191
|
+
| `description` | `""` | Home page meta description; falls back to `about_html`. |
|
|
192
|
+
| `og_image` | `None` | Link-preview image (absolute URL). |
|
|
189
193
|
|
|
190
194
|
For a non-English game, set `wikipedia_lang` so the default image lookup and the
|
|
191
195
|
*"know more"* link resolve against that language's Wikipedia (e.g. `"pt"`). It
|
|
@@ -204,6 +208,44 @@ app = create_app(config, instance_static="/path/to/static")
|
|
|
204
208
|
# served at /static/... , falling back to the engine's static files
|
|
205
209
|
```
|
|
206
210
|
|
|
211
|
+
## Search discoverability
|
|
212
|
+
|
|
213
|
+
One game is served from many URLs: `/`, a `/<slug>` landing page per category,
|
|
214
|
+
and `/about`. The category you are playing is a JavaScript variable, so nothing
|
|
215
|
+
in the markup distinguishes those pages — left alone they are one page repeated,
|
|
216
|
+
and a crawler indexes one and discards the rest.
|
|
217
|
+
|
|
218
|
+
The engine gives each of them its own metadata, derived from what the instance
|
|
219
|
+
already declares:
|
|
220
|
+
|
|
221
|
+
| Page | Title | Description | Canonical |
|
|
222
|
+
| --- | --- | --- | --- |
|
|
223
|
+
| `/` | `{title} — {subtitle}` | `description`, else the opening of `about_html` | `/` |
|
|
224
|
+
| `/<slug>` | `{category} — {title}` | `{category} — {description}` | the category's short slug |
|
|
225
|
+
| `/about` | `{label_about} — {title}` | as the home page | `/about` |
|
|
226
|
+
|
|
227
|
+
The title and description patterns are `home_title`, `category_title` and
|
|
228
|
+
`category_description` on `MemiConfig`; a category pattern can also use
|
|
229
|
+
`{count}`, the number of items in it. Defaults are punctuation only, so a
|
|
230
|
+
localized game stays in its own language without touching them.
|
|
231
|
+
|
|
232
|
+
Two more things fall out of this:
|
|
233
|
+
|
|
234
|
+
- **`/robots.txt`** opens the site, excludes `/api/` (JSON a crawler can do
|
|
235
|
+
nothing with) and names the sitemap.
|
|
236
|
+
- **`/sitemap.xml`** lists `/`, `/about` and every category — built from the
|
|
237
|
+
registry, so a new provider appears without an edit. A category whose last
|
|
238
|
+
segment is unique answers at both `/food` and `/culture-food`; only the short
|
|
239
|
+
one is listed, and the long one canonicalises to it.
|
|
240
|
+
|
|
241
|
+
Canonical and `og:` URLs need the site's real origin. Behind a reverse proxy
|
|
242
|
+
that sends `X-Forwarded-Proto` and `X-Forwarded-Host` (Caddy does by default)
|
|
243
|
+
the engine works it out per request. Set `site_url` — or `MEMI_SITE_URL` — to
|
|
244
|
+
pin it instead.
|
|
245
|
+
|
|
246
|
+
Set **`html_lang`** per instance. It defaults to `"en"`, and a Portuguese game
|
|
247
|
+
that tells Google it is English is a game shown to the wrong people.
|
|
248
|
+
|
|
207
249
|
## Deployment
|
|
208
250
|
|
|
209
251
|
The app is a standard WSGI Flask app. For production, install the `server`
|
|
@@ -220,9 +262,10 @@ players report a bad card).
|
|
|
220
262
|
|
|
221
263
|
## Live examples
|
|
222
264
|
|
|
223
|
-
Real games built on this engine
|
|
224
|
-
[memi
|
|
225
|
-
[memi
|
|
265
|
+
Real games built on this engine, all linked from
|
|
266
|
+
[memi.games](https://memi.games): [memi](https://world.memi.games) ·
|
|
267
|
+
[memi portugal](https://pt.memi.games) · [memi lisboa](https://lx.memi.games) ·
|
|
268
|
+
[memi slovensko](https://sk.memi.games) · [memi US](https://us.memi.games).
|
|
226
269
|
|
|
227
270
|
## Development
|
|
228
271
|
|
|
@@ -11,7 +11,6 @@ import logging
|
|
|
11
11
|
import os
|
|
12
12
|
import random
|
|
13
13
|
import subprocess
|
|
14
|
-
from collections import Counter
|
|
15
14
|
|
|
16
15
|
from flask import (
|
|
17
16
|
Flask,
|
|
@@ -22,8 +21,9 @@ from flask import (
|
|
|
22
21
|
request,
|
|
23
22
|
send_from_directory,
|
|
24
23
|
)
|
|
24
|
+
from werkzeug.middleware.proxy_fix import ProxyFix
|
|
25
25
|
|
|
26
|
-
from memi_engine import images, registry
|
|
26
|
+
from memi_engine import images, registry, seo
|
|
27
27
|
from memi_engine.config import MemiConfig
|
|
28
28
|
from memi_engine.menu import build_menu
|
|
29
29
|
|
|
@@ -54,6 +54,12 @@ def create_app(config: MemiConfig, instance_static: str | None = None) -> Flask:
|
|
|
54
54
|
static_folder=None,
|
|
55
55
|
)
|
|
56
56
|
|
|
57
|
+
# Every memi site runs behind Caddy, which terminates HTTPS and proxies to
|
|
58
|
+
# gunicorn on localhost. Without this the app only ever sees the plain-HTTP
|
|
59
|
+
# inner hop, and would publish canonical and og: URLs on http://127.0.0.1.
|
|
60
|
+
# One hop, and only the two headers that decide the origin.
|
|
61
|
+
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=0, x_proto=1, x_host=1)
|
|
62
|
+
|
|
57
63
|
# Custom static file handler: instance first, then engine
|
|
58
64
|
static_dirs = (
|
|
59
65
|
[instance_static, engine_static] if instance_static else [engine_static]
|
|
@@ -97,7 +103,26 @@ def create_app(config: MemiConfig, instance_static: str | None = None) -> Flask:
|
|
|
97
103
|
|
|
98
104
|
# --- Routes ---
|
|
99
105
|
|
|
100
|
-
def
|
|
106
|
+
def _site_url() -> str:
|
|
107
|
+
"""Origin that canonical, og: and sitemap URLs are built on."""
|
|
108
|
+
pinned = config.site_url or os.environ.get("MEMI_SITE_URL")
|
|
109
|
+
return (pinned or request.url_root).rstrip("/")
|
|
110
|
+
|
|
111
|
+
def _page(meta: dict, path: str) -> dict:
|
|
112
|
+
"""Head metadata for one page: what it is, and where it really lives."""
|
|
113
|
+
return {
|
|
114
|
+
"title": meta["title"],
|
|
115
|
+
"description": meta["description"],
|
|
116
|
+
"canonical": _site_url() + path,
|
|
117
|
+
"og_image": config.og_image,
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
def _category_page_meta(key: str) -> dict:
|
|
121
|
+
provider = registry.get(key)
|
|
122
|
+
count = len(provider.items) if provider else 0
|
|
123
|
+
return seo.category_meta(config, key, count)
|
|
124
|
+
|
|
125
|
+
def _render_index(initial_category=None, page=None):
|
|
101
126
|
top_level, subs = build_menu()
|
|
102
127
|
# Collect all unique filters from all providers
|
|
103
128
|
all_filters = _collect_filters()
|
|
@@ -109,6 +134,7 @@ def create_app(config: MemiConfig, instance_static: str | None = None) -> Flask:
|
|
|
109
134
|
config=config,
|
|
110
135
|
filters=all_filters,
|
|
111
136
|
initial_category=initial_category,
|
|
137
|
+
page=page or _page(seo.home_meta(config), "/"),
|
|
112
138
|
)
|
|
113
139
|
|
|
114
140
|
@app.route("/")
|
|
@@ -120,21 +146,74 @@ def create_app(config: MemiConfig, instance_static: str | None = None) -> Flask:
|
|
|
120
146
|
initial = cat if registry.get(cat) else None
|
|
121
147
|
if initial is None and registry.get(config.default_category or ""):
|
|
122
148
|
initial = config.default_category
|
|
123
|
-
|
|
149
|
+
# `?cat=` is the same page as that category's own URL, so it points at
|
|
150
|
+
# it rather than competing with it. A bare `/` is the home page and
|
|
151
|
+
# canonicalises to itself, even when a default category is showing.
|
|
152
|
+
page = None
|
|
153
|
+
if cat and initial:
|
|
154
|
+
slug = seo.canonical_slugs(registry.get_all()).get(initial)
|
|
155
|
+
if slug:
|
|
156
|
+
page = _page(_category_page_meta(initial), f"/{slug}")
|
|
157
|
+
return _render_index(initial, page)
|
|
124
158
|
|
|
125
159
|
@app.route("/<slug>")
|
|
126
160
|
def category_landing(slug):
|
|
127
161
|
# Pretty per-category landing pages, e.g. /food -> culture:food.
|
|
128
|
-
key =
|
|
162
|
+
key = seo.category_slugs(registry.get_all()).get(slug)
|
|
129
163
|
if key is None:
|
|
130
164
|
abort(404)
|
|
131
|
-
|
|
165
|
+
# A category with a unique last segment answers at both /food and
|
|
166
|
+
# /culture-food. Both keep working; the canonical names the short one
|
|
167
|
+
# so the pair is never indexed as two pages.
|
|
168
|
+
canonical = seo.canonical_slugs(registry.get_all()).get(key, slug)
|
|
169
|
+
return _render_index(key, _page(_category_page_meta(key), f"/{canonical}"))
|
|
132
170
|
|
|
133
171
|
@app.route("/about")
|
|
134
172
|
def about():
|
|
135
173
|
return render_template(
|
|
136
|
-
"about.html",
|
|
174
|
+
"about.html",
|
|
175
|
+
version=config.version,
|
|
176
|
+
config=config,
|
|
177
|
+
page=_page(seo.about_meta(config), "/about"),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
@app.route("/robots.txt")
|
|
181
|
+
def robots_txt():
|
|
182
|
+
"""Open the site to crawlers and point them at the sitemap.
|
|
183
|
+
|
|
184
|
+
The game data endpoints are excluded: they answer with JSON a crawler
|
|
185
|
+
can do nothing with, and /api/random is different on every request.
|
|
186
|
+
"""
|
|
187
|
+
body = (
|
|
188
|
+
"User-agent: *\n"
|
|
189
|
+
"Allow: /\n"
|
|
190
|
+
"Disallow: /api/\n"
|
|
191
|
+
"\n"
|
|
192
|
+
f"Sitemap: {_site_url()}/sitemap.xml\n"
|
|
137
193
|
)
|
|
194
|
+
return Response(body, mimetype="text/plain")
|
|
195
|
+
|
|
196
|
+
@app.route("/sitemap.xml")
|
|
197
|
+
def sitemap_xml():
|
|
198
|
+
"""Every page worth indexing, built from the registry.
|
|
199
|
+
|
|
200
|
+
The category pages are reachable in the UI only by clicking a button,
|
|
201
|
+
so without this a crawler finds the home page and nothing else. A new
|
|
202
|
+
provider appears here the moment it registers — nothing to maintain.
|
|
203
|
+
"""
|
|
204
|
+
base = _site_url()
|
|
205
|
+
paths = ["/", "/about"]
|
|
206
|
+
paths += [
|
|
207
|
+
f"/{slug}"
|
|
208
|
+
for slug in sorted(seo.canonical_slugs(registry.get_all()).values())
|
|
209
|
+
]
|
|
210
|
+
urls = "".join(f"<url><loc>{base}{path}</loc></url>" for path in paths)
|
|
211
|
+
body = (
|
|
212
|
+
'<?xml version="1.0" encoding="UTF-8"?>'
|
|
213
|
+
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
|
|
214
|
+
f"{urls}</urlset>"
|
|
215
|
+
)
|
|
216
|
+
return Response(body, mimetype="application/xml")
|
|
138
217
|
|
|
139
218
|
@app.route("/api/random")
|
|
140
219
|
def random_item():
|
|
@@ -228,25 +307,6 @@ def create_app(config: MemiConfig, instance_static: str | None = None) -> Flask:
|
|
|
228
307
|
return app
|
|
229
308
|
|
|
230
309
|
|
|
231
|
-
def _category_slugs() -> dict[str, str]:
|
|
232
|
-
"""Map shareable URL slugs to category keys, derived from the registry.
|
|
233
|
-
|
|
234
|
-
A category key's last ``:``-segment is its slug when that segment is unique
|
|
235
|
-
(``culture:food`` -> ``food``); the full dashed key is always available too
|
|
236
|
-
(``culture-food``), and is the *only* slug for segments that would collide
|
|
237
|
-
(e.g. several ``…:all`` categories, which all end in ``all``).
|
|
238
|
-
"""
|
|
239
|
-
keys = list(registry.get_all())
|
|
240
|
-
last = {k: k.rsplit(":", 1)[-1] for k in keys}
|
|
241
|
-
counts = Counter(last.values())
|
|
242
|
-
slugs: dict[str, str] = {}
|
|
243
|
-
for k in keys:
|
|
244
|
-
if counts[last[k]] == 1:
|
|
245
|
-
slugs[last[k]] = k
|
|
246
|
-
slugs.setdefault(k.replace(":", "-"), k)
|
|
247
|
-
return slugs
|
|
248
|
-
|
|
249
|
-
|
|
250
310
|
def _collect_filters() -> dict:
|
|
251
311
|
"""Collect all unique filters across all providers.
|
|
252
312
|
|
|
@@ -68,3 +68,38 @@ class MemiConfig:
|
|
|
68
68
|
|
|
69
69
|
# Wikipedia language edition for the default image / "know more" helpers.
|
|
70
70
|
wikipedia_lang: str = "en"
|
|
71
|
+
|
|
72
|
+
# --- Search engines and link previews ---
|
|
73
|
+
#
|
|
74
|
+
# Every field here has a working default derived from the fields above, so
|
|
75
|
+
# a game gets per-page titles, descriptions and canonicals without setting
|
|
76
|
+
# any of them. See memi_engine/seo.py.
|
|
77
|
+
|
|
78
|
+
# Canonical origin, e.g. "https://pt.memi.games". Leave None to take it
|
|
79
|
+
# from the request (correct behind the usual Caddy reverse proxy, which
|
|
80
|
+
# sends X-Forwarded-Proto and X-Forwarded-Host). Set it — or the
|
|
81
|
+
# MEMI_SITE_URL env var — to pin canonical URLs to one host regardless of
|
|
82
|
+
# what a request claims.
|
|
83
|
+
site_url: str | None = None
|
|
84
|
+
|
|
85
|
+
# <html lang>. The engine is English; the games mostly are not, and a page
|
|
86
|
+
# that declares the wrong language is a page Google shows to the wrong
|
|
87
|
+
# people. Set it per instance ("pt", "sk", "ca").
|
|
88
|
+
html_lang: str = "en"
|
|
89
|
+
|
|
90
|
+
# Meta description for the home page. Empty falls back to the opening of
|
|
91
|
+
# about_html, which is already written in the instance's language.
|
|
92
|
+
description: str = ""
|
|
93
|
+
|
|
94
|
+
# Absolute URL of the link-preview image (og:image). None omits the tag;
|
|
95
|
+
# the card still renders from the title and description.
|
|
96
|
+
og_image: str | None = None
|
|
97
|
+
|
|
98
|
+
# Title patterns. Placeholders: {title}, {subtitle} and, for a category
|
|
99
|
+
# page, {category} — the category's label as the menu shows it.
|
|
100
|
+
home_title: str = "{title} — {subtitle}"
|
|
101
|
+
category_title: str = "{category} — {title}"
|
|
102
|
+
|
|
103
|
+
# Description pattern for a category page. {description} is the site
|
|
104
|
+
# description resolved above.
|
|
105
|
+
category_description: str = "{category} — {description}"
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Search-engine metadata for a memi instance.
|
|
2
|
+
|
|
3
|
+
The engine serves one game from many URLs — ``/``, a ``/<slug>`` landing page
|
|
4
|
+
per category, and ``/about``. Until each of those carries its own title,
|
|
5
|
+
description and canonical they are, to a crawler, the same page repeated: the
|
|
6
|
+
category is only ever a JavaScript variable, so nothing in the markup says
|
|
7
|
+
which one you are looking at.
|
|
8
|
+
|
|
9
|
+
Everything here is derived from what an instance already declares — its title,
|
|
10
|
+
subtitle, about copy and registered categories — so a game gets per-page
|
|
11
|
+
metadata without adding configuration. The patterns on ``MemiConfig`` are the
|
|
12
|
+
override for instances that want their own wording.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from collections import Counter
|
|
19
|
+
|
|
20
|
+
# Roughly where Google truncates the snippet. Not a hard rule on their side,
|
|
21
|
+
# but a description that survives intact reads better than an ellipsis.
|
|
22
|
+
DESCRIPTION_MAX = 160
|
|
23
|
+
|
|
24
|
+
_TAG_RE = re.compile(r"<[^>]+>")
|
|
25
|
+
_WS_RE = re.compile(r"\s+")
|
|
26
|
+
_ENTITIES = {
|
|
27
|
+
"—": "—", "–": "–", " ": " ",
|
|
28
|
+
"&": "&", "<": "<", ">": ">", """: '"', "'": "'",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def strip_html(html: str) -> str:
|
|
33
|
+
"""Plain text from a fragment of HTML, whitespace collapsed.
|
|
34
|
+
|
|
35
|
+
``about_html`` is hand-written per instance and is the one place a game
|
|
36
|
+
already describes itself in its own language, which makes it the best
|
|
37
|
+
fallback for a meta description.
|
|
38
|
+
"""
|
|
39
|
+
text = _TAG_RE.sub(" ", html or "")
|
|
40
|
+
for entity, char in _ENTITIES.items():
|
|
41
|
+
text = text.replace(entity, char)
|
|
42
|
+
return _WS_RE.sub(" ", text).strip()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def shorten(text: str, limit: int = DESCRIPTION_MAX) -> str:
|
|
46
|
+
"""Trim to ``limit`` characters, preferring a sentence then a word break."""
|
|
47
|
+
text = text.strip()
|
|
48
|
+
if len(text) <= limit:
|
|
49
|
+
return text
|
|
50
|
+
window = text[: limit + 1]
|
|
51
|
+
for end in (". ", "! ", "? "):
|
|
52
|
+
cut = window.rfind(end)
|
|
53
|
+
if cut > limit // 2:
|
|
54
|
+
return window[: cut + 1].strip()
|
|
55
|
+
cut = window.rfind(" ")
|
|
56
|
+
trimmed = (window[:cut] if cut > 0 else window[:limit]).rstrip(" ,;:-–—")
|
|
57
|
+
# A cut that happens to land on a full stop is already a clean ending; an
|
|
58
|
+
# ellipsis after it just reads as a typo.
|
|
59
|
+
return trimmed if trimmed.endswith((".", "!", "?")) else trimmed + "…"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def category_label(key: str) -> str:
|
|
63
|
+
"""Display label for a category key — its last segment, as the menu shows it.
|
|
64
|
+
|
|
65
|
+
``"cultura:monumentos"`` -> ``"monumentos"``.
|
|
66
|
+
"""
|
|
67
|
+
return key.rsplit(":", 1)[-1]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def category_slugs(keys) -> dict[str, str]:
|
|
71
|
+
"""Map every URL slug to its category key.
|
|
72
|
+
|
|
73
|
+
A key's last ``:``-segment is a slug when that segment is unique
|
|
74
|
+
(``culture:food`` -> ``food``); the full dashed key is always available too
|
|
75
|
+
(``culture-food``), and is the *only* slug for segments that would collide
|
|
76
|
+
(e.g. several ``…:all`` categories, which all end in ``all``).
|
|
77
|
+
"""
|
|
78
|
+
keys = list(keys)
|
|
79
|
+
last = {k: category_label(k) for k in keys}
|
|
80
|
+
counts = Counter(last.values())
|
|
81
|
+
slugs: dict[str, str] = {}
|
|
82
|
+
for k in keys:
|
|
83
|
+
if counts[last[k]] == 1:
|
|
84
|
+
slugs[last[k]] = k
|
|
85
|
+
slugs.setdefault(k.replace(":", "-"), k)
|
|
86
|
+
return slugs
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def canonical_slugs(keys) -> dict[str, str]:
|
|
90
|
+
"""Map each category key to the one slug that represents it.
|
|
91
|
+
|
|
92
|
+
The inverse of :func:`category_slugs`, and the reason it exists: a category
|
|
93
|
+
with a unique last segment is reachable at both ``/food`` and
|
|
94
|
+
``/culture-food``. Both keep working — links to either are already out
|
|
95
|
+
there — but only the short one goes in the sitemap, and the long one
|
|
96
|
+
canonicalises to it, so the pair is never indexed as two pages.
|
|
97
|
+
"""
|
|
98
|
+
preferred: dict[str, str] = {}
|
|
99
|
+
for slug, key in category_slugs(keys).items():
|
|
100
|
+
current = preferred.get(key)
|
|
101
|
+
if current is None or len(slug) < len(current):
|
|
102
|
+
preferred[key] = slug
|
|
103
|
+
return preferred
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def site_description(config) -> str:
|
|
107
|
+
"""The instance's own description, in the instance's own language.
|
|
108
|
+
|
|
109
|
+
``MemiConfig.description`` when set, else the opening of ``about_html``,
|
|
110
|
+
which every game writes in its own language. The title/subtitle pair is the
|
|
111
|
+
last resort: short, but never empty and never the wrong language.
|
|
112
|
+
"""
|
|
113
|
+
if config.description:
|
|
114
|
+
return config.description
|
|
115
|
+
about = shorten(strip_html(config.about_html or ""))
|
|
116
|
+
return about or f"{config.title} — {config.subtitle}"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _format(pattern: str, **values: str) -> str:
|
|
120
|
+
"""Apply a config pattern, tolerating a placeholder the instance dropped."""
|
|
121
|
+
try:
|
|
122
|
+
return pattern.format(**values).strip()
|
|
123
|
+
except (KeyError, IndexError):
|
|
124
|
+
return values.get("title", "")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def home_meta(config) -> dict[str, str]:
|
|
128
|
+
return {
|
|
129
|
+
"title": _format(
|
|
130
|
+
config.home_title, title=config.title, subtitle=config.subtitle
|
|
131
|
+
),
|
|
132
|
+
"description": site_description(config),
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def about_meta(config) -> dict[str, str]:
|
|
137
|
+
return {
|
|
138
|
+
"title": f"{config.label_about} — {config.title}",
|
|
139
|
+
"description": site_description(config),
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def category_meta(config, key: str, count: int = 0) -> dict[str, str]:
|
|
144
|
+
"""Title and description for one category's landing page.
|
|
145
|
+
|
|
146
|
+
``count`` is how many items the category holds; it is not in either default
|
|
147
|
+
pattern, but an instance can use ``{count}`` to say so ("monumentos — 34
|
|
148
|
+
monumentos portugueses to name").
|
|
149
|
+
"""
|
|
150
|
+
values = {
|
|
151
|
+
"category": category_label(key),
|
|
152
|
+
"title": config.title,
|
|
153
|
+
"subtitle": config.subtitle,
|
|
154
|
+
"count": str(count),
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
"title": _format(config.category_title, **values),
|
|
158
|
+
"description": shorten(
|
|
159
|
+
_format(
|
|
160
|
+
config.category_description,
|
|
161
|
+
description=site_description(config),
|
|
162
|
+
**values,
|
|
163
|
+
)
|
|
164
|
+
),
|
|
165
|
+
}
|
|
@@ -1,15 +1,7 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
|
-
<html lang="
|
|
2
|
+
<html lang="{{ config.html_lang }}">
|
|
3
3
|
<head>
|
|
4
|
-
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
-
<title>About - {{ config.title }}</title>
|
|
7
|
-
<link rel="icon" type="image/svg+xml" href="{{ url_for('favicon') }}?v={{ version }}">
|
|
8
|
-
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}?v={{ version }}">
|
|
9
|
-
<script>
|
|
10
|
-
var t = localStorage.getItem('memi-theme') || '{{ config.default_theme }}';
|
|
11
|
-
document.documentElement.dataset.theme = t;
|
|
12
|
-
</script>
|
|
4
|
+
{% include "head.html" %}
|
|
13
5
|
</head>
|
|
14
6
|
<body>
|
|
15
7
|
<div class="theme-picker">
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{# Shared <head>. `page` comes from app.py and is per-URL: the home page, the
|
|
2
|
+
about page and each category landing page each get their own title,
|
|
3
|
+
description and canonical. Without that they are one page repeated, because
|
|
4
|
+
which category you are looking at is otherwise only a JavaScript variable. #}
|
|
5
|
+
<meta charset="UTF-8">
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
7
|
+
<title>{{ page.title }}</title>
|
|
8
|
+
<meta name="description" content="{{ page.description }}">
|
|
9
|
+
{# /food and /culture-food are the same category; the canonical picks one. #}
|
|
10
|
+
<link rel="canonical" href="{{ page.canonical }}">
|
|
11
|
+
<meta property="og:type" content="website">
|
|
12
|
+
<meta property="og:site_name" content="{{ config.title }}">
|
|
13
|
+
<meta property="og:title" content="{{ page.title }}">
|
|
14
|
+
<meta property="og:description" content="{{ page.description }}">
|
|
15
|
+
<meta property="og:url" content="{{ page.canonical }}">
|
|
16
|
+
{%- if page.og_image %}
|
|
17
|
+
<meta property="og:image" content="{{ page.og_image }}">
|
|
18
|
+
<meta name="twitter:card" content="summary_large_image">
|
|
19
|
+
{%- else %}
|
|
20
|
+
{# No image: the card still renders, just small. #}
|
|
21
|
+
<meta name="twitter:card" content="summary">
|
|
22
|
+
{%- endif %}
|
|
23
|
+
<link rel="icon" type="image/svg+xml" href="{{ url_for('favicon') }}?v={{ version }}">
|
|
24
|
+
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}?v={{ version }}">
|
|
25
|
+
<script>
|
|
26
|
+
var t = localStorage.getItem('memi-theme') || '{{ config.default_theme }}';
|
|
27
|
+
document.documentElement.dataset.theme = t;
|
|
28
|
+
</script>
|
|
@@ -1,15 +1,7 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
|
-
<html lang="
|
|
2
|
+
<html lang="{{ config.html_lang }}">
|
|
3
3
|
<head>
|
|
4
|
-
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
-
<title>{{ config.title }}</title>
|
|
7
|
-
<link rel="icon" type="image/svg+xml" href="{{ url_for('favicon') }}?v={{ version }}">
|
|
8
|
-
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}?v={{ version }}">
|
|
9
|
-
<script>
|
|
10
|
-
var t = localStorage.getItem('memi-theme') || '{{ config.default_theme }}';
|
|
11
|
-
document.documentElement.dataset.theme = t;
|
|
12
|
-
</script>
|
|
4
|
+
{% include "head.html" %}
|
|
13
5
|
</head>
|
|
14
6
|
<body>
|
|
15
7
|
<div class="theme-picker">
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "memi-engine"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Engine for building memi memory card game instances"
|
|
5
5
|
license = "MIT"
|
|
6
6
|
license-files = ["LICENSE"]
|
|
@@ -32,7 +32,7 @@ server = ["gunicorn>=22"]
|
|
|
32
32
|
dev = ["pytest>=8", "ruff>=0.6"]
|
|
33
33
|
|
|
34
34
|
[project.urls]
|
|
35
|
-
Homepage = "https://memi.
|
|
35
|
+
Homepage = "https://memi.games"
|
|
36
36
|
Repository = "https://github.com/filias/memi-engine"
|
|
37
37
|
Issues = "https://github.com/filias/memi-engine/issues"
|
|
38
38
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|