memi-engine 0.4.0__tar.gz → 0.4.2__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.
Files changed (21) hide show
  1. {memi_engine-0.4.0 → memi_engine-0.4.2}/PKG-INFO +1 -1
  2. {memi_engine-0.4.0 → memi_engine-0.4.2}/memi_engine/app.py +1 -0
  3. {memi_engine-0.4.0 → memi_engine-0.4.2}/memi_engine/seo.py +56 -16
  4. {memi_engine-0.4.0 → memi_engine-0.4.2}/memi_engine/static/css/style.css +12 -0
  5. {memi_engine-0.4.0 → memi_engine-0.4.2}/memi_engine/static/js/app.js +16 -0
  6. {memi_engine-0.4.0 → memi_engine-0.4.2}/memi_engine/templates/index.html +3 -0
  7. {memi_engine-0.4.0 → memi_engine-0.4.2}/pyproject.toml +1 -1
  8. {memi_engine-0.4.0 → memi_engine-0.4.2}/.gitignore +0 -0
  9. {memi_engine-0.4.0 → memi_engine-0.4.2}/LICENSE +0 -0
  10. {memi_engine-0.4.0 → memi_engine-0.4.2}/README.md +0 -0
  11. {memi_engine-0.4.0 → memi_engine-0.4.2}/memi_engine/__init__.py +0 -0
  12. {memi_engine-0.4.0 → memi_engine-0.4.2}/memi_engine/config.py +0 -0
  13. {memi_engine-0.4.0 → memi_engine-0.4.2}/memi_engine/images.py +0 -0
  14. {memi_engine-0.4.0 → memi_engine-0.4.2}/memi_engine/menu.py +0 -0
  15. {memi_engine-0.4.0 → memi_engine-0.4.2}/memi_engine/provider.py +0 -0
  16. {memi_engine-0.4.0 → memi_engine-0.4.2}/memi_engine/py.typed +0 -0
  17. {memi_engine-0.4.0 → memi_engine-0.4.2}/memi_engine/registry.py +0 -0
  18. {memi_engine-0.4.0 → memi_engine-0.4.2}/memi_engine/scientific.py +0 -0
  19. {memi_engine-0.4.0 → memi_engine-0.4.2}/memi_engine/scientific_names.py +0 -0
  20. {memi_engine-0.4.0 → memi_engine-0.4.2}/memi_engine/templates/about.html +0 -0
  21. {memi_engine-0.4.0 → memi_engine-0.4.2}/memi_engine/templates/head.html +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: memi-engine
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: Engine for building memi memory card game instances
5
5
  Project-URL: Homepage, https://memi.games
6
6
  Project-URL: Repository, https://github.com/filias/memi-engine
@@ -136,6 +136,7 @@ def create_app(config: MemiConfig, instance_static: str | None = None) -> Flask:
136
136
  config=config,
137
137
  filters=all_filters,
138
138
  initial_category=initial_category,
139
+ category_slugs=seo.canonical_slugs(registry.get_all()),
139
140
  page=page or _page(seo.home_meta(config), "/"),
140
141
  )
141
142
 
@@ -15,6 +15,7 @@ override for instances that want their own wording.
15
15
  from __future__ import annotations
16
16
 
17
17
  import re
18
+ import unicodedata
18
19
  from collections import Counter
19
20
 
20
21
  # Roughly where Google truncates the snippet. Not a hard rule on their side,
@@ -67,40 +68,79 @@ def category_label(key: str) -> str:
67
68
  return key.rsplit(":", 1)[-1]
68
69
 
69
70
 
71
+ # Letters NFKD doesn't decompose into an ASCII base + accent.
72
+ _ASCII_FOLD = str.maketrans(
73
+ {"ł": "l", "Ł": "L", "ø": "o", "Ø": "O", "ß": "ss", "đ": "d"}
74
+ )
75
+ _APOSTROPHES = re.compile(r"['’ʼ`]")
76
+ _NON_SLUG = re.compile(r"[^a-z0-9]+")
77
+
78
+
79
+ def slugify(text: str) -> str:
80
+ """URL-safe form of a category name: ``"marta’s tips"`` -> ``"martas-tips"``.
81
+
82
+ Lowercase ASCII letters and digits joined by dashes; accents are dropped
83
+ (``"monumentos históricos"`` -> ``"monumentos-historicos"``) and apostrophes
84
+ vanish rather than splitting the word. Empty when nothing ASCII is left
85
+ (a name in a non-Latin script), and the caller then keeps the raw name.
86
+ """
87
+ folded = unicodedata.normalize("NFKD", text.translate(_ASCII_FOLD))
88
+ ascii_text = folded.encode("ascii", "ignore").decode().lower()
89
+ return _NON_SLUG.sub("-", _APOSTROPHES.sub("", ascii_text)).strip("-")
90
+
91
+
92
+ def _preferred_slugs(keys) -> dict[str, str]:
93
+ """The one slug each key is shared and indexed under.
94
+
95
+ The slugified last segment when no other key's last segment slugifies the
96
+ same (``culture:food`` -> ``food``), else the slugified full key
97
+ (``nature-all``). A name with nothing ASCII in it keeps its raw form.
98
+ """
99
+ def slug_or_raw(text):
100
+ return slugify(text) or text
101
+
102
+ short = {k: slug_or_raw(category_label(k)) for k in keys}
103
+ counts = Counter(short.values())
104
+ return {
105
+ k: short[k] if counts[short[k]] == 1 else slug_or_raw(k.replace(":", "-"))
106
+ for k in keys
107
+ }
108
+
109
+
70
110
  def category_slugs(keys) -> dict[str, str]:
71
111
  """Map every URL slug to its category key.
72
112
 
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``).
113
+ Each key answers at its preferred slug (see :func:`canonical_slugs`) and,
114
+ so that links already shared keep working, at the raw forms earlier
115
+ versions used: the last segment when unique (``marta’s tips``) and the full
116
+ dashed key (``culture-food``). The raw forms canonicalise to the preferred
117
+ one, so the duplicates are never indexed.
77
118
  """
78
119
  keys = list(keys)
120
+ preferred = _preferred_slugs(keys)
79
121
  last = {k: category_label(k) for k in keys}
80
122
  counts = Counter(last.values())
81
123
  slugs: dict[str, str] = {}
124
+ for k in keys:
125
+ slugs[preferred[k]] = k
82
126
  for k in keys:
83
127
  if counts[last[k]] == 1:
84
- slugs[last[k]] = k
128
+ slugs.setdefault(last[k], k)
85
129
  slugs.setdefault(k.replace(":", "-"), k)
130
+ slugs.setdefault(slugify(k.replace(":", "-")) or k, k)
86
131
  return slugs
87
132
 
88
133
 
89
134
  def canonical_slugs(keys) -> dict[str, str]:
90
135
  """Map each category key to the one slug that represents it.
91
136
 
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.
137
+ The inverse of :func:`category_slugs`: a category answers at several
138
+ slugs (``/food`` and ``/culture-food``; ``/martas-tips`` and the raw
139
+ ``/marta’s tips``). All keep working — links to them are already out
140
+ there — but only this one goes in the sitemap and the address bar, and the
141
+ others canonicalise to it, so they are never indexed as separate pages.
97
142
  """
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
143
+ return _preferred_slugs(list(keys))
104
144
 
105
145
 
106
146
  def site_description(config) -> str:
@@ -699,6 +699,18 @@ h1 a {
699
699
  gap: 0.75rem;
700
700
  }
701
701
 
702
+ .details-title {
703
+ margin: 0;
704
+ font-size: 1.3rem;
705
+ line-height: 1.25;
706
+ color: var(--heading);
707
+ }
708
+
709
+ /* Directly under the title, closer than the panel's usual gap. */
710
+ .details-title + .details-area {
711
+ margin-top: -0.5rem;
712
+ }
713
+
702
714
  .details-area {
703
715
  align-self: flex-start;
704
716
  font-size: 0.8rem;
@@ -66,6 +66,7 @@ function toggleSubmenu(parent) {
66
66
  selectedCategories = [];
67
67
  seenItems = [];
68
68
  menuStack = [];
69
+ syncUrl();
69
70
  renderSubmenu(subcategories[parent], parent);
70
71
  }
71
72
 
@@ -141,7 +142,20 @@ function closeSubmenu() {
141
142
 
142
143
  // --- Generic filter system ---
143
144
 
145
+ // Keep the address bar on the shareable URL of what's being played: /<slug>
146
+ // while exactly one category is selected, / otherwise. replaceState, so moving
147
+ // between categories doesn't pile up back-button entries.
148
+ function syncUrl() {
149
+ if (typeof CATEGORY_SLUGS === 'undefined' || !window.history.replaceState) return;
150
+ const slug = selectedCategories.length === 1 ? CATEGORY_SLUGS[selectedCategories[0]] : null;
151
+ const path = slug ? '/' + encodeURIComponent(slug) : '/';
152
+ if (window.location.pathname !== path || window.location.search) {
153
+ window.history.replaceState(null, '', path);
154
+ }
155
+ }
156
+
144
157
  function updateFilters() {
158
+ syncUrl();
145
159
  for (const [filterName, filterInfo] of Object.entries(FILTER_CONFIG)) {
146
160
  const el = document.getElementById(filterName + '-filter');
147
161
  if (!el) continue;
@@ -359,6 +373,8 @@ function osmEmbedUrl(lat, lon, zoom, width, height) {
359
373
  function showDetails(details) {
360
374
  const panel = document.getElementById('details');
361
375
  if (!panel || !details) return;
376
+ // Headed by the card's answer; the area (if any) sits underneath it.
377
+ document.getElementById('details-title').textContent = currentName;
362
378
  const area = document.getElementById('details-area');
363
379
  const text = document.getElementById('details-text');
364
380
  const map = document.getElementById('details-map');
@@ -59,6 +59,7 @@
59
59
  </div>
60
60
  {% if config.layout == "enriched" %}
61
61
  <aside class="details" id="details" hidden>
62
+ <h2 class="details-title" id="details-title"></h2>
62
63
  <a class="details-area" id="details-area" target="_blank" rel="noopener" hidden></a>
63
64
  <p class="details-text" id="details-text" hidden></p>
64
65
  <div class="details-map" id="details-map" hidden></div>
@@ -100,6 +101,8 @@
100
101
  // Deep-link target: a category key when the page was opened via /<slug> or
101
102
  // ?cat=<key>, else null. app.js opens straight into it on load.
102
103
  var INITIAL_CATEGORY = {{ (initial_category or none) | tojson }};
104
+ // Each category's shareable path (/<slug>); the address bar follows the game.
105
+ var CATEGORY_SLUGS = {{ category_slugs | tojson }};
103
106
  </script>
104
107
  <script id="subcategories-data" type="application/json">{{ subcategories | tojson }}</script>
105
108
  <script src="{{ url_for('static', filename='js/app.js') }}?v={{ version }}"></script>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "memi-engine"
3
- version = "0.4.0"
3
+ version = "0.4.2"
4
4
  description = "Engine for building memi memory card game instances"
5
5
  license = "MIT"
6
6
  license-files = ["LICENSE"]
File without changes
File without changes
File without changes