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.
@@ -1,8 +1,8 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: memi-engine
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Engine for building memi memory card game instances
5
- Project-URL: Homepage, https://memi.click
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.click) game — a
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: [memi](https://memi.click) ·
257
- [memi portugal](https://pt.memi.click) · [memi lisboa](https://lx.memi.click) ·
258
- [memi slovensko](https://sk.memi.click) · [memi US](https://us.memi.click).
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.click) game — a
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: [memi](https://memi.click) ·
224
- [memi portugal](https://pt.memi.click) · [memi lisboa](https://lx.memi.click) ·
225
- [memi slovensko](https://sk.memi.click) · [memi US](https://us.memi.click).
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 _render_index(initial_category=None):
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
- return _render_index(initial)
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 = _category_slugs().get(slug)
162
+ key = seo.category_slugs(registry.get_all()).get(slug)
129
163
  if key is None:
130
164
  abort(404)
131
- return _render_index(key)
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", version=config.version, config=config
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
+ "&mdash;": "—", "&ndash;": "–", "&nbsp;": " ",
28
+ "&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": '"', "&#39;": "'",
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="en">
2
+ <html lang="{{ config.html_lang }}">
3
3
  <head>
4
- <meta charset="UTF-8">
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="en">
2
+ <html lang="{{ config.html_lang }}">
3
3
  <head>
4
- <meta charset="UTF-8">
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.2.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.click"
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