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.
- {memi_engine-0.4.0 → memi_engine-0.4.2}/PKG-INFO +1 -1
- {memi_engine-0.4.0 → memi_engine-0.4.2}/memi_engine/app.py +1 -0
- {memi_engine-0.4.0 → memi_engine-0.4.2}/memi_engine/seo.py +56 -16
- {memi_engine-0.4.0 → memi_engine-0.4.2}/memi_engine/static/css/style.css +12 -0
- {memi_engine-0.4.0 → memi_engine-0.4.2}/memi_engine/static/js/app.js +16 -0
- {memi_engine-0.4.0 → memi_engine-0.4.2}/memi_engine/templates/index.html +3 -0
- {memi_engine-0.4.0 → memi_engine-0.4.2}/pyproject.toml +1 -1
- {memi_engine-0.4.0 → memi_engine-0.4.2}/.gitignore +0 -0
- {memi_engine-0.4.0 → memi_engine-0.4.2}/LICENSE +0 -0
- {memi_engine-0.4.0 → memi_engine-0.4.2}/README.md +0 -0
- {memi_engine-0.4.0 → memi_engine-0.4.2}/memi_engine/__init__.py +0 -0
- {memi_engine-0.4.0 → memi_engine-0.4.2}/memi_engine/config.py +0 -0
- {memi_engine-0.4.0 → memi_engine-0.4.2}/memi_engine/images.py +0 -0
- {memi_engine-0.4.0 → memi_engine-0.4.2}/memi_engine/menu.py +0 -0
- {memi_engine-0.4.0 → memi_engine-0.4.2}/memi_engine/provider.py +0 -0
- {memi_engine-0.4.0 → memi_engine-0.4.2}/memi_engine/py.typed +0 -0
- {memi_engine-0.4.0 → memi_engine-0.4.2}/memi_engine/registry.py +0 -0
- {memi_engine-0.4.0 → memi_engine-0.4.2}/memi_engine/scientific.py +0 -0
- {memi_engine-0.4.0 → memi_engine-0.4.2}/memi_engine/scientific_names.py +0 -0
- {memi_engine-0.4.0 → memi_engine-0.4.2}/memi_engine/templates/about.html +0 -0
- {memi_engine-0.4.0 → memi_engine-0.4.2}/memi_engine/templates/head.html +0 -0
|
@@ -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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
(
|
|
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
|
|
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
|
|
93
|
-
|
|
94
|
-
``/
|
|
95
|
-
there — but only
|
|
96
|
-
|
|
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
|
-
|
|
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>
|
|
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
|
|
File without changes
|
|
File without changes
|