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.
- flask_sitecopy-0.2.0/.github/workflows/ci.yml +47 -0
- flask_sitecopy-0.2.0/.github/workflows/release.yml +35 -0
- flask_sitecopy-0.2.0/.gitignore +19 -0
- flask_sitecopy-0.2.0/LICENSE +21 -0
- flask_sitecopy-0.2.0/PKG-INFO +398 -0
- flask_sitecopy-0.2.0/README.md +369 -0
- flask_sitecopy-0.2.0/docs/TESTING.md +205 -0
- flask_sitecopy-0.2.0/example/README.md +52 -0
- flask_sitecopy-0.2.0/example/__init__.py +0 -0
- flask_sitecopy-0.2.0/example/app.py +119 -0
- flask_sitecopy-0.2.0/example/registry.py +181 -0
- flask_sitecopy-0.2.0/example/static/site.css +132 -0
- flask_sitecopy-0.2.0/example/templates/about.html +11 -0
- flask_sitecopy-0.2.0/example/templates/base.html +31 -0
- flask_sitecopy-0.2.0/example/templates/index.html +41 -0
- flask_sitecopy-0.2.0/example/templates/product.html +23 -0
- flask_sitecopy-0.2.0/pyproject.toml +68 -0
- flask_sitecopy-0.2.0/src/sitecopy/__init__.py +105 -0
- flask_sitecopy-0.2.0/src/sitecopy/admin.py +788 -0
- flask_sitecopy-0.2.0/src/sitecopy/auth.py +100 -0
- flask_sitecopy-0.2.0/src/sitecopy/csrf.py +58 -0
- flask_sitecopy-0.2.0/src/sitecopy/editor_markup.py +321 -0
- flask_sitecopy-0.2.0/src/sitecopy/extension.py +159 -0
- flask_sitecopy-0.2.0/src/sitecopy/registry.py +202 -0
- flask_sitecopy-0.2.0/src/sitecopy/resolver.py +558 -0
- flask_sitecopy-0.2.0/src/sitecopy/sanitizer.py +270 -0
- flask_sitecopy-0.2.0/src/sitecopy/state.py +76 -0
- flask_sitecopy-0.2.0/src/sitecopy/static/css/editor-frame.css +215 -0
- flask_sitecopy-0.2.0/src/sitecopy/static/css/sitecopy-admin.css +238 -0
- flask_sitecopy-0.2.0/src/sitecopy/static/css/sitecopy-editor.css +331 -0
- flask_sitecopy-0.2.0/src/sitecopy/static/css/sitecopy-shell.css +232 -0
- flask_sitecopy-0.2.0/src/sitecopy/static/js/editor-frame.js +791 -0
- flask_sitecopy-0.2.0/src/sitecopy/static/js/sitecopy-admin.js +251 -0
- flask_sitecopy-0.2.0/src/sitecopy/static/js/sitecopy-editor.js +1343 -0
- flask_sitecopy-0.2.0/src/sitecopy/storage.py +443 -0
- flask_sitecopy-0.2.0/src/sitecopy/templates/sitecopy/base.html +78 -0
- flask_sitecopy-0.2.0/src/sitecopy/templates/sitecopy/editor.html +228 -0
- flask_sitecopy-0.2.0/src/sitecopy/templates/sitecopy/group.html +117 -0
- flask_sitecopy-0.2.0/src/sitecopy/templates/sitecopy/index.html +72 -0
- flask_sitecopy-0.2.0/src/sitecopy/templates/sitecopy/login.html +34 -0
- flask_sitecopy-0.2.0/src/sitecopy/templates/sitecopy/not_found.html +16 -0
- flask_sitecopy-0.2.0/src/sitecopy/templates/sitecopy/preview.html +126 -0
- flask_sitecopy-0.2.0/src/sitecopy/testing.py +139 -0
- flask_sitecopy-0.2.0/tests/appfactory.py +141 -0
- flask_sitecopy-0.2.0/tests/conftest.py +30 -0
- flask_sitecopy-0.2.0/tests/e2e/_server.py +25 -0
- flask_sitecopy-0.2.0/tests/e2e/conftest.py +174 -0
- flask_sitecopy-0.2.0/tests/e2e/test_a11y.py +68 -0
- flask_sitecopy-0.2.0/tests/e2e/test_editor.py +216 -0
- flask_sitecopy-0.2.0/tests/test_admin.py +430 -0
- flask_sitecopy-0.2.0/tests/test_concurrency.py +80 -0
- flask_sitecopy-0.2.0/tests/test_csrf.py +139 -0
- flask_sitecopy-0.2.0/tests/test_editor_markup.py +258 -0
- flask_sitecopy-0.2.0/tests/test_encoding.py +58 -0
- flask_sitecopy-0.2.0/tests/test_example.py +97 -0
- flask_sitecopy-0.2.0/tests/test_extension.py +250 -0
- flask_sitecopy-0.2.0/tests/test_lines_token_newline.py +91 -0
- flask_sitecopy-0.2.0/tests/test_properties.py +233 -0
- flask_sitecopy-0.2.0/tests/test_registry.py +187 -0
- flask_sitecopy-0.2.0/tests/test_resolver.py +342 -0
- flask_sitecopy-0.2.0/tests/test_sanitizer.py +252 -0
- 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.
|