flask-sitecopy 0.2.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.
Files changed (62) hide show
  1. flask_sitecopy-0.2.0/.github/workflows/ci.yml +47 -0
  2. flask_sitecopy-0.2.0/.github/workflows/release.yml +35 -0
  3. flask_sitecopy-0.2.0/.gitignore +19 -0
  4. flask_sitecopy-0.2.0/LICENSE +21 -0
  5. flask_sitecopy-0.2.0/PKG-INFO +398 -0
  6. flask_sitecopy-0.2.0/README.md +369 -0
  7. flask_sitecopy-0.2.0/docs/TESTING.md +205 -0
  8. flask_sitecopy-0.2.0/example/README.md +52 -0
  9. flask_sitecopy-0.2.0/example/__init__.py +0 -0
  10. flask_sitecopy-0.2.0/example/app.py +119 -0
  11. flask_sitecopy-0.2.0/example/registry.py +181 -0
  12. flask_sitecopy-0.2.0/example/static/site.css +132 -0
  13. flask_sitecopy-0.2.0/example/templates/about.html +11 -0
  14. flask_sitecopy-0.2.0/example/templates/base.html +31 -0
  15. flask_sitecopy-0.2.0/example/templates/index.html +41 -0
  16. flask_sitecopy-0.2.0/example/templates/product.html +23 -0
  17. flask_sitecopy-0.2.0/pyproject.toml +68 -0
  18. flask_sitecopy-0.2.0/src/sitecopy/__init__.py +105 -0
  19. flask_sitecopy-0.2.0/src/sitecopy/admin.py +788 -0
  20. flask_sitecopy-0.2.0/src/sitecopy/auth.py +100 -0
  21. flask_sitecopy-0.2.0/src/sitecopy/csrf.py +58 -0
  22. flask_sitecopy-0.2.0/src/sitecopy/editor_markup.py +321 -0
  23. flask_sitecopy-0.2.0/src/sitecopy/extension.py +159 -0
  24. flask_sitecopy-0.2.0/src/sitecopy/registry.py +202 -0
  25. flask_sitecopy-0.2.0/src/sitecopy/resolver.py +558 -0
  26. flask_sitecopy-0.2.0/src/sitecopy/sanitizer.py +270 -0
  27. flask_sitecopy-0.2.0/src/sitecopy/state.py +76 -0
  28. flask_sitecopy-0.2.0/src/sitecopy/static/css/editor-frame.css +215 -0
  29. flask_sitecopy-0.2.0/src/sitecopy/static/css/sitecopy-admin.css +238 -0
  30. flask_sitecopy-0.2.0/src/sitecopy/static/css/sitecopy-editor.css +331 -0
  31. flask_sitecopy-0.2.0/src/sitecopy/static/css/sitecopy-shell.css +232 -0
  32. flask_sitecopy-0.2.0/src/sitecopy/static/js/editor-frame.js +791 -0
  33. flask_sitecopy-0.2.0/src/sitecopy/static/js/sitecopy-admin.js +251 -0
  34. flask_sitecopy-0.2.0/src/sitecopy/static/js/sitecopy-editor.js +1343 -0
  35. flask_sitecopy-0.2.0/src/sitecopy/storage.py +443 -0
  36. flask_sitecopy-0.2.0/src/sitecopy/templates/sitecopy/base.html +78 -0
  37. flask_sitecopy-0.2.0/src/sitecopy/templates/sitecopy/editor.html +228 -0
  38. flask_sitecopy-0.2.0/src/sitecopy/templates/sitecopy/group.html +117 -0
  39. flask_sitecopy-0.2.0/src/sitecopy/templates/sitecopy/index.html +72 -0
  40. flask_sitecopy-0.2.0/src/sitecopy/templates/sitecopy/login.html +34 -0
  41. flask_sitecopy-0.2.0/src/sitecopy/templates/sitecopy/not_found.html +16 -0
  42. flask_sitecopy-0.2.0/src/sitecopy/templates/sitecopy/preview.html +126 -0
  43. flask_sitecopy-0.2.0/src/sitecopy/testing.py +139 -0
  44. flask_sitecopy-0.2.0/tests/appfactory.py +141 -0
  45. flask_sitecopy-0.2.0/tests/conftest.py +30 -0
  46. flask_sitecopy-0.2.0/tests/e2e/_server.py +25 -0
  47. flask_sitecopy-0.2.0/tests/e2e/conftest.py +174 -0
  48. flask_sitecopy-0.2.0/tests/e2e/test_a11y.py +68 -0
  49. flask_sitecopy-0.2.0/tests/e2e/test_editor.py +216 -0
  50. flask_sitecopy-0.2.0/tests/test_admin.py +430 -0
  51. flask_sitecopy-0.2.0/tests/test_concurrency.py +80 -0
  52. flask_sitecopy-0.2.0/tests/test_csrf.py +139 -0
  53. flask_sitecopy-0.2.0/tests/test_editor_markup.py +258 -0
  54. flask_sitecopy-0.2.0/tests/test_encoding.py +58 -0
  55. flask_sitecopy-0.2.0/tests/test_example.py +97 -0
  56. flask_sitecopy-0.2.0/tests/test_extension.py +250 -0
  57. flask_sitecopy-0.2.0/tests/test_lines_token_newline.py +91 -0
  58. flask_sitecopy-0.2.0/tests/test_properties.py +233 -0
  59. flask_sitecopy-0.2.0/tests/test_registry.py +187 -0
  60. flask_sitecopy-0.2.0/tests/test_resolver.py +342 -0
  61. flask_sitecopy-0.2.0/tests/test_sanitizer.py +252 -0
  62. flask_sitecopy-0.2.0/tests/test_storage.py +228 -0
@@ -0,0 +1,47 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: ["**"]
6
+ pull_request:
7
+
8
+ concurrency:
9
+ group: ci-${{ github.ref }}
10
+ cancel-in-progress: true
11
+
12
+ jobs:
13
+ test:
14
+ name: tests (py${{ matrix.python-version }})
15
+ runs-on: ubuntu-latest
16
+ strategy:
17
+ fail-fast: false
18
+ matrix:
19
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+ - uses: actions/setup-python@v5
23
+ with:
24
+ python-version: ${{ matrix.python-version }}
25
+ cache: pip
26
+ - name: Install
27
+ run: pip install -e ".[test]"
28
+ - name: Unit + integration + property tests (with coverage)
29
+ # Coverage gate (fail_under) lives in pyproject; --cov-fail-under here is belt
30
+ # and braces so a config drift can't silently drop the floor.
31
+ run: pytest --cov --cov-report=term-missing --cov-fail-under=94
32
+
33
+ e2e:
34
+ name: e2e (editor, Playwright)
35
+ runs-on: ubuntu-latest
36
+ steps:
37
+ - uses: actions/checkout@v4
38
+ - uses: actions/setup-python@v5
39
+ with:
40
+ python-version: "3.12"
41
+ cache: pip
42
+ - name: Install
43
+ run: pip install -e ".[test,e2e]"
44
+ - name: Install Chromium
45
+ run: python -m playwright install --with-deps chromium
46
+ - name: Editor end-to-end suite
47
+ run: pytest tests/e2e -m e2e
@@ -0,0 +1,35 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ jobs:
8
+ publish:
9
+ name: build + publish to PyPI
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ # Trusted publishing: PyPI verifies this short-lived OIDC token instead of a
13
+ # stored API key, so there is no long-lived secret to leak or rotate.
14
+ id-token: write
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: actions/setup-python@v5
18
+ with:
19
+ python-version: "3.12"
20
+
21
+ - name: Tag must match the version in pyproject
22
+ # A tag that drifted from the packaged version is what left v0.1.0 pointing at
23
+ # stale code. Fail the release rather than publish a version nobody reviewed.
24
+ run: |
25
+ tag="${GITHUB_REF_NAME#v}"
26
+ pkg=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
27
+ if [ "$tag" != "$pkg" ]; then
28
+ echo "tag ${GITHUB_REF_NAME} declares $tag but pyproject says $pkg"
29
+ exit 1
30
+ fi
31
+
32
+ - name: Build sdist + wheel
33
+ run: pipx run build
34
+
35
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,19 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ build/
5
+ dist/
6
+ .venv/
7
+ venv/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .coverage
11
+ htmlcov/
12
+ .DS_Store
13
+ instance/
14
+ example/instance/
15
+
16
+ # mutation testing scratch
17
+ mutants/
18
+ .mutmut-cache
19
+ html/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Camilo Romero
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,398 @@
1
+ Metadata-Version: 2.4
2
+ Name: flask-sitecopy
3
+ Version: 0.2.0
4
+ Summary: Edit every string on a Flask site from an admin panel, without a deploy.
5
+ Project-URL: Homepage, https://github.com/CROCDC/flask-sitecopy
6
+ Project-URL: Source, https://github.com/CROCDC/flask-sitecopy
7
+ Author: Camilo Romero
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: cms,content,copy,editor,flask,i18n
11
+ Classifier: Framework :: Flask
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
15
+ Requires-Python: >=3.10
16
+ Requires-Dist: flask>=2.2
17
+ Requires-Dist: markupsafe>=2.0
18
+ Provides-Extra: e2e
19
+ Requires-Dist: axe-playwright-python>=0.1; extra == 'e2e'
20
+ Requires-Dist: pytest-playwright>=0.4; extra == 'e2e'
21
+ Provides-Extra: sqlalchemy
22
+ Requires-Dist: flask-sqlalchemy>=3.0; extra == 'sqlalchemy'
23
+ Provides-Extra: test
24
+ Requires-Dist: flask-sqlalchemy>=3.0; extra == 'test'
25
+ Requires-Dist: hypothesis>=6.0; extra == 'test'
26
+ Requires-Dist: pytest-cov>=4.0; extra == 'test'
27
+ Requires-Dist: pytest>=7.0; extra == 'test'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # flask-sitecopy
31
+
32
+ Every user-facing string on a Flask site, editable from an admin panel — in place, on
33
+ the real page — without a deploy.
34
+
35
+ ```python
36
+ from sitecopy import SiteCopy, Registry, Group, Section, TextField
37
+
38
+ REGISTRY = Registry(groups=(
39
+ Group("home", "Inicio", "La página principal", sections=(
40
+ Section("hero", "Portada", fields=(
41
+ TextField("home.hero.title", "Título", "Bolsos de cuero vegano"),
42
+ TextField("home.hero.cta", "Botón", "Ver la colección"),
43
+ )),
44
+ )),
45
+ ))
46
+
47
+ SiteCopy(app, registry=REGISTRY, db=db, password="una-clave")
48
+ ```
49
+
50
+ ```jinja
51
+ <h1>{{ t('home.hero.title') }}</h1>
52
+ ```
53
+
54
+ That is the whole install. `/admin/content` now shows the site in a frame; click any
55
+ text on the page and type over it. Adding new copy later is one `TextField` plus one
56
+ `t('<key>')` — no migration, no seed, no admin form to touch.
57
+
58
+ ## Try it
59
+
60
+ A complete little site lives in [`example/`](example/). It touches every field type,
61
+ tokens, `external_content` and the draft/publish flow:
62
+
63
+ ```bash
64
+ python -m venv .venv && . .venv/bin/activate # see the note below
65
+ pip install -e ".[test]" # the library + Flask-SQLAlchemy
66
+ python -m example.app # http://127.0.0.1:5000
67
+ ```
68
+
69
+ > On Debian/Ubuntu, a distro-packaged `blinker` makes a global `pip install` abort with
70
+ > `Cannot uninstall blinker … RECORD file not found`. A virtualenv (above) sidesteps it.
71
+
72
+ Open `/` for the public site and `/admin/content/` for the editor (password: `demo`).
73
+ See [`example/README.md`](example/README.md) for what each part demonstrates.
74
+
75
+ ---
76
+
77
+ ## Why it is shaped like this
78
+
79
+ **The registry is the source of truth; the database stores overrides only.** A key with
80
+ no row renders its default straight from the code. So a fresh database renders exactly
81
+ what the code says, there is no seeding step and no "staging says something different"
82
+ class of bug, "restore the original" is a row delete, and new copy never needs a data
83
+ migration.
84
+
85
+ Resolution order, lowest priority first:
86
+
87
+ | source | when it wins |
88
+ |----------------------------|--------------------------------------------------|
89
+ | `TextField.default` (code) | always, unless overridden |
90
+ | `published_value` | whenever the row exists |
91
+ | `draft_value` | only in preview mode, for a logged-in admin |
92
+
93
+ **Keys are an API.** A key is the primary key of the override row, so renaming one
94
+ silently drops whatever the editor wrote there. If you must rename one, migrate the row.
95
+
96
+ **A content lookup can never break the site.** A missing key, an unreachable database
97
+ or a half-written draft degrades to the registry default. In debug/test an unknown key
98
+ raises instead, so a typo fails loudly in CI rather than rendering an empty heading in
99
+ production.
100
+
101
+ ---
102
+
103
+ ## Install
104
+
105
+ ```
106
+ pip install flask-sitecopy
107
+ ```
108
+
109
+ For local work on the library itself: `pip install -e ../flask-sitecopy`.
110
+
111
+ ---
112
+
113
+ ## Wiring
114
+
115
+ ```python
116
+ sitecopy = SiteCopy()
117
+
118
+ def create_app():
119
+ app = Flask(__name__)
120
+ Compress(app) # if you use it — see the note below
121
+ db.init_app(app)
122
+
123
+ sitecopy.init_app(
124
+ app,
125
+ registry=REGISTRY,
126
+ db=db,
127
+ login_required=my_admin.login_required, # optional, see Auth
128
+ is_logged_in=my_admin.is_logged_in,
129
+ base_template="admin/base.html", # optional, see Chrome
130
+ pages=my_editor_pages, # optional, see Pages
131
+ brand=lambda: str(t("global.brand")),
132
+ site_url=app.config["SITE_URL"],
133
+ )
134
+
135
+ with app.app_context():
136
+ db.create_all()
137
+ sitecopy.ensure_schema() # creates/repairs the overrides table
138
+ return app
139
+ ```
140
+
141
+ **Order matters with compression.** Flask runs `after_request` hooks in reverse
142
+ registration order, and the editor rewrites the HTML of an `?edit=1` response — so
143
+ `init_app` has to come *after* `Compress(app)`, or the rewrite sees a gzipped body.
144
+
145
+ ### Options
146
+
147
+ | option | default | what it does |
148
+ |--------------------|----------------------|--------------|
149
+ | `registry` | — (required) | the catalogue of editable strings |
150
+ | `db` | — | a Flask-SQLAlchemy instance; builds the bundled `site_texts` table |
151
+ | `store` | — | any `TextStore`, instead of `db` |
152
+ | `table_name` | `"site_texts"` | for the bundled store |
153
+ | `url_prefix` | `"/admin/content"` | where the panel is mounted |
154
+ | `login_required` | bundled password | your view decorator |
155
+ | `is_logged_in` | bundled password | your "is this an admin?" predicate |
156
+ | `password` | `SITECOPY_PASSWORD` | the bundled login's shared password |
157
+ | `base_template` | `sitecopy/base.html` | the admin chrome the screens extend |
158
+ | `pages` | every GET route | the visual editor's page picker |
159
+ | `brand` | — | str or callable, shown in the chrome and the SERP card |
160
+ | `site_url` | `""` | canonical origin, for the share/search cards |
161
+ | `external_content` | — | `{"selector": …, "message": …}`, see below |
162
+ | `nav` | `[]` | extra links for the bundled chrome |
163
+ | `blueprint_name` | `"sitecopy"` | rename to mount two registries on one app |
164
+
165
+ ### Auth
166
+
167
+ Pass **both** `login_required` (a view decorator) and `is_logged_in` (a predicate) to
168
+ reuse the site's own admin session. They answer different questions: one guards the
169
+ panel's screens, the other gates preview and edit mode on every *public* page — which
170
+ is what keeps unpublished copy from reaching the public through a shared link. Passing
171
+ one without the other raises.
172
+
173
+ Pass neither and the bundled shared-password login is mounted at `<url_prefix>/login`,
174
+ reading `SITECOPY_PASSWORD` from the app config.
175
+
176
+ ### CSRF
177
+
178
+ Every state-changing panel request (save, publish, revert, discard, the group form, even
179
+ login) carries a per-session token, sent in the `X-Sitecopy-CSRF` header by the editor or
180
+ a hidden `_sitecopy_csrf` field by the no-JS forms. It is on by default. A host that
181
+ already runs its own CSRF layer (Flask-WTF, say) can turn it off with `SITECOPY_CSRF =
182
+ False` in the app config and rely on its own.
183
+
184
+ ### Chrome
185
+
186
+ The bundled `base_template` is self-contained. To put the screens inside an existing
187
+ admin, pass yours; it needs four blocks — `title`, `head`, `content`, `scripts` — and
188
+ should link nothing of its own that the screens depend on (they carry their own CSS).
189
+ The screens set `sitecopy_screen` (`editor` / `index` / `group` / `preview` / `login`)
190
+ and `full_bleed`, so a shared layout can highlight the right nav item and let the editor
191
+ use the full width.
192
+
193
+ ### Pages
194
+
195
+ The visual editor moves around the site with an explicit picker: clicking a link inside
196
+ the canvas is ambiguous (is that a click, or an edit?). By default every argument-free
197
+ GET route is offered. A site that knows its own sitemap — which product, which category
198
+ — passes a callable:
199
+
200
+ ```python
201
+ def editor_pages():
202
+ return [
203
+ {"path": "/", "label": "Inicio"},
204
+ {"path": f"/producto/{first_product().id}", "label": "Producto"},
205
+ ]
206
+ ```
207
+
208
+ This list is also what the canvas is allowed to START on: `?path=` only accepts a page
209
+ that appears here, so an admin screen — or the editor itself — can never be loaded into
210
+ the frame. Following a link *inside* the canvas still reaches the whole site.
211
+
212
+ ### Text the site does not own
213
+
214
+ Some text on the page comes from a catalogue or a feed, not from the registry. Tell the
215
+ editor where it lives so a click on it says so:
216
+
217
+ ```python
218
+ external_content={
219
+ "selector": ".product, .cart-line",
220
+ "message": "Esto sale del catálogo: el título y el precio se editan en Productos.",
221
+ }
222
+ ```
223
+
224
+ ---
225
+
226
+ ## Field types
227
+
228
+ | type | widget | rendering | use for |
229
+ |---------|----------|------------------------------|--------------------------------------|
230
+ | `line` | input | escaped | titles, labels, buttons, aria-labels |
231
+ | `text` | textarea | escaped | paragraphs |
232
+ | `lines` | textarea | a list, one item per line | bullet lists, marquees |
233
+ | `rich` | textarea | allow-list sanitized HTML | editorial/legal page bodies |
234
+ | `url` | input | validated `http(s)` link | social and external links |
235
+
236
+ `rich` accepts only `p h2 h3 ul ol li strong b em i a br`. Everything else is stripped
237
+ (tags dropped, their text kept); `script`/`style`/`iframe`/`svg` are dropped *with*
238
+ their content. Rich values are sanitized on save **and** on render — the second pass is
239
+ deliberate: a value that reached the table some other way (a restored backup, a manual
240
+ `UPDATE`) must not be able to inject script into a public page. `url` values are
241
+ re-checked on render for the same reason, falling back to the registry default.
242
+
243
+ ---
244
+
245
+ ## Tokens
246
+
247
+ Any string may embed `{token}`. Unknown tokens are left literal — an editor typing a
248
+ stray brace never raises mid-render.
249
+
250
+ **Site-wide tokens** are registry fields promoted with `tokens=`:
251
+
252
+ ```python
253
+ Registry(
254
+ groups=(...),
255
+ tokens=("global.brand", "global.instagram_url", "global.tagline"),
256
+ )
257
+ ```
258
+
259
+ `{brand}`, `{instagram_url}` and `{tagline}` are now available to every string. They
260
+ resolve **in the order given**, each able to use the ones before it, so declare the one
261
+ that mentions the others last. `{year}` is always available.
262
+
263
+ **Per-call tokens** are passed by the template or route, and declared so the admin's
264
+ validation knows about them:
265
+
266
+ ```python
267
+ Registry(..., field_tokens={"product.meta.title": ("title", "category")})
268
+ ```
269
+
270
+ ```jinja
271
+ {{ t('product.meta.title', title=product.title, category=product.category) }}
272
+ ```
273
+
274
+ Tokens are interpolated **before** sanitizing, so a token's value is treated as data
275
+ (escaped), never as markup.
276
+
277
+ ---
278
+
279
+ ## The visual editor
280
+
281
+ `/admin/content/` is the front door: the live site in a frame, edited in place.
282
+
283
+ - **Click any text and type over it.** Nothing is live until you publish.
284
+ - **Copy with no visible text** — the `<title>`, the meta description, image `alt`s,
285
+ aria-labels — is in the side panel. Clicking an image opens its alt text there.
286
+ - **Unsaved edits travel with you** across pages, and stay listed in the panel.
287
+ - **Device widths** and **share/search cards** (Google, WhatsApp, Twitter/X) are built
288
+ from the previewed document's own `<title>` and `meta` tags, so there is no second
289
+ implementation of your metadata logic to drift out of sync.
290
+ - `/admin/content/list` is the same copy as a list of forms — how you find one specific
291
+ string, and the path that works with JavaScript off.
292
+
293
+ ### How a click maps back to a field
294
+
295
+ In edit mode the resolver wraps every value it returns in private-use markers carrying
296
+ its key. A response hook rewrites those, once per response:
297
+
298
+ | where the value landed | becomes |
299
+ |-------------------------------------|----------------------------------------------------|
300
+ | visible text | `<ct-t data-k="key">…</ct-t>`, click-to-edit |
301
+ | an attribute (`alt`, `aria-label`) | stripped; the key is recorded as `data-ct-keys` |
302
+ | `<title>` / `<script>` / `<style>` | stripped; the key goes to the panel |
303
+
304
+ Two consequences worth keeping in mind: **new copy is covered automatically** (nothing
305
+ is annotated by hand), and **the public render is untouched** — markers only exist when
306
+ a logged-in admin asks for `?edit=1`.
307
+
308
+ ### Values that are serialized, not rendered
309
+
310
+ Strings that ship as inline JSON for a script, or that are built into JSON-LD in Python,
311
+ must use `t_plain()` — the marker-free variant. A marker inside `json.dumps` output
312
+ would survive as literal `\uXXXX` text. It still records the key, so the panel lists it.
313
+
314
+ ---
315
+
316
+ ## Draft → preview → publish
317
+
318
+ ```
319
+ Guardar borrador writes draft_value; the live site does not change
320
+ Previsualizar opens the REAL page with ?preview=1, drafts applied
321
+ Guardar y publicar promotes drafts to published_value
322
+ Volver al texto original drafts the registry default
323
+ Volver a lo que decía antes drafts previous_value
324
+ Deshacer drafts the step back for everything the last publish put live
325
+ ```
326
+
327
+ A saved value equal to what is already live clears the draft instead of storing a no-op,
328
+ so the "sin publicar" counter only ever counts real pending changes.
329
+
330
+ Publishing records the wording it replaced (`previous_value`), so a published mistake
331
+ has a way back that is not "retype what you remember". Both undo controls only ever
332
+ leave a **draft**: nothing in the editor changes the public site except "Publicar
333
+ cambios".
334
+
335
+ Publishing from the visual editor publishes **the keys that editor is holding**, and the
336
+ confirm names them — a colleague's half-finished text parked in another tab does not
337
+ ride along. Discard carries the same scope.
338
+
339
+ `?preview=1` on any public URL switches the resolver to drafts, **only** when the
340
+ request carries an admin session. From the public it is a no-op. Preview responses carry
341
+ `X-Robots-Tag: noindex, nofollow` and `Cache-Control: no-store`.
342
+
343
+ ---
344
+
345
+ ## Storage
346
+
347
+ The bundled store is one small table on your Flask-SQLAlchemy `db`. Anything that
348
+ answers the `TextStore` methods works instead:
349
+
350
+ ```python
351
+ from sitecopy import TextStore, MemoryStore
352
+
353
+ SiteCopy(app, registry=REGISTRY, store=MemoryStore())
354
+ ```
355
+
356
+ `MemoryStore` keeps everything in the process — useful in tests, and for a site that
357
+ ships its copy read-only.
358
+
359
+ The resolver reads the overrides **once per request** and caches that on the request. It
360
+ is deliberately not cached in the process: several workers typically share one database,
361
+ so a process-level cache would keep serving stale copy in the other workers after an
362
+ edit, with no way to invalidate across processes.
363
+
364
+ ---
365
+
366
+ ## Testing your registry
367
+
368
+ ```python
369
+ from sitecopy.testing import check_registry, check_templates
370
+
371
+ def test_registry_is_sound():
372
+ assert check_registry(REGISTRY) == []
373
+
374
+ def test_every_key_is_rendered_and_every_rendered_key_exists(app):
375
+ assert check_templates(REGISTRY, "app/templates") == []
376
+ ```
377
+
378
+ `check_registry` enforces the contract: unique keys, non-empty defaults that fit their
379
+ own `max_length`, rich defaults that survive the sanitizer, and tokens that point at
380
+ fields that exist. `check_templates` scans your Jinja templates for `t('…')` calls and
381
+ reports keys a template uses but the registry does not declare — and declared keys
382
+ nothing renders.
383
+
384
+ ---
385
+
386
+ ## Limitations worth knowing
387
+
388
+ - **The admin UI is in Spanish.** The library is not translated; every screen, hint and
389
+ error message is Spanish. The public-facing copy is of course whatever your registry
390
+ says.
391
+ - **Flask + Jinja only.** The editor works by rewriting rendered HTML, so it has to be
392
+ in the render path.
393
+ - **One shared password** in the bundled auth, deliberately. If you need accounts and
394
+ roles, you already have an admin — pass its `login_required`.
395
+
396
+ ## License
397
+
398
+ MIT.