pretty-notebook 0.10.0rc2__py3-none-any.whl
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.
- pnbp/__init__.py +2 -0
- pnbp/_icons.py +5 -0
- pnbp/_identities.py +5 -0
- pnbp/_journal.py +5 -0
- pnbp/_links.py +5 -0
- pnbp/_moves.py +5 -0
- pnbp/_navigation.py +5 -0
- pnbp/_publication_execute.py +5 -0
- pnbp/_publication_plan.py +5 -0
- pnbp/_publication_state.py +5 -0
- pnbp/_publishing.py +5 -0
- pnbp/_rendering.py +5 -0
- pnbp/_routes.py +5 -0
- pnbp/_search.py +5 -0
- pnbp/_storage.py +5 -0
- pnbp/cli.py +5 -0
- pnbp/commands/__init__.py +2 -0
- pnbp/commands/code.py +5 -0
- pnbp/commands/collect.py +5 -0
- pnbp/commands/commit.py +5 -0
- pnbp/commands/correct.py +5 -0
- pnbp/commands/deploy.py +5 -0
- pnbp/commands/graph.py +5 -0
- pnbp/commands/pprint.py +5 -0
- pnbp/commands/subl.py +5 -0
- pnbp/commands/tasks.py +5 -0
- pnbp/helpers/__init__.py +2 -0
- pnbp/helpers/_datetime.py +5 -0
- pnbp/helpers/wrappers.py +5 -0
- pnbp/models/__init__.py +2 -0
- pnbp/models/components/__init__.py +2 -0
- pnbp/models/components/codeblock.py +5 -0
- pnbp/models/components/component.py +5 -0
- pnbp/models/components/link.py +5 -0
- pnbp/models/components/tag.py +5 -0
- pnbp/models/components/url.py +5 -0
- pnbp/models/note.py +5 -0
- pnbp/models/notebook.py +5 -0
- pnbp/settings.py +5 -0
- pretty_notebook/__init__.py +7 -0
- pretty_notebook/_internal/__init__.py +1 -0
- pretty_notebook/_internal/deployment.py +184 -0
- pretty_notebook/_internal/icons.py +51 -0
- pretty_notebook/_internal/identities.py +295 -0
- pretty_notebook/_internal/journal.py +305 -0
- pretty_notebook/_internal/links.py +187 -0
- pretty_notebook/_internal/moves.py +168 -0
- pretty_notebook/_internal/navigation.py +242 -0
- pretty_notebook/_internal/publication_execute.py +125 -0
- pretty_notebook/_internal/publication_plan.py +296 -0
- pretty_notebook/_internal/publication_state.py +111 -0
- pretty_notebook/_internal/publishing.py +447 -0
- pretty_notebook/_internal/rendering.py +163 -0
- pretty_notebook/_internal/routes.py +82 -0
- pretty_notebook/_internal/search.py +113 -0
- pretty_notebook/_internal/storage.py +271 -0
- pretty_notebook/appearance.py +63 -0
- pretty_notebook/cli.py +533 -0
- pretty_notebook/commands/__init__.py +0 -0
- pretty_notebook/commands/code.py +173 -0
- pretty_notebook/commands/collect.py +249 -0
- pretty_notebook/commands/commit.py +281 -0
- pretty_notebook/commands/correct.py +234 -0
- pretty_notebook/commands/deploy.py +63 -0
- pretty_notebook/commands/graph.py +189 -0
- pretty_notebook/commands/pprint.py +81 -0
- pretty_notebook/commands/subl.py +75 -0
- pretty_notebook/commands/tasks.py +323 -0
- pretty_notebook/helpers/__init__.py +2 -0
- pretty_notebook/helpers/_datetime.py +26 -0
- pretty_notebook/helpers/wrappers.py +77 -0
- pretty_notebook/models/__init__.py +4 -0
- pretty_notebook/models/components/__init__.py +13 -0
- pretty_notebook/models/components/codeblock.py +123 -0
- pretty_notebook/models/components/component.py +79 -0
- pretty_notebook/models/components/link.py +252 -0
- pretty_notebook/models/components/tag.py +72 -0
- pretty_notebook/models/components/url.py +104 -0
- pretty_notebook/models/note.py +504 -0
- pretty_notebook/models/notebook.py +633 -0
- pretty_notebook/resources/deploy/checkpoint.py.tmpl +42 -0
- pretty_notebook/resources/deploy/common.sh.tmpl +56 -0
- pretty_notebook/resources/deploy/environment.example.tmpl +10 -0
- pretty_notebook/resources/deploy/init.sh.tmpl +29 -0
- pretty_notebook/resources/deploy/nginx.conf.tmpl +33 -0
- pretty_notebook/resources/deploy/rollback.sh.tmpl +27 -0
- pretty_notebook/resources/deploy/service.tmpl +25 -0
- pretty_notebook/resources/deploy/upgrade.sh.tmpl +36 -0
- pretty_notebook/settings.py +422 -0
- pretty_notebook-0.10.0rc2.dist-info/METADATA +77 -0
- pretty_notebook-0.10.0rc2.dist-info/RECORD +95 -0
- pretty_notebook-0.10.0rc2.dist-info/WHEEL +5 -0
- pretty_notebook-0.10.0rc2.dist-info/entry_points.txt +2 -0
- pretty_notebook-0.10.0rc2.dist-info/licenses/LICENSE +22 -0
- pretty_notebook-0.10.0rc2.dist-info/top_level.txt +2 -0
pnbp/__init__.py
ADDED
pnbp/_icons.py
ADDED
pnbp/_identities.py
ADDED
pnbp/_journal.py
ADDED
pnbp/_links.py
ADDED
pnbp/_moves.py
ADDED
pnbp/_navigation.py
ADDED
pnbp/_publishing.py
ADDED
pnbp/_rendering.py
ADDED
pnbp/_routes.py
ADDED
pnbp/_search.py
ADDED
pnbp/_storage.py
ADDED
pnbp/cli.py
ADDED
pnbp/commands/code.py
ADDED
pnbp/commands/collect.py
ADDED
pnbp/commands/commit.py
ADDED
pnbp/commands/correct.py
ADDED
pnbp/commands/deploy.py
ADDED
pnbp/commands/graph.py
ADDED
pnbp/commands/pprint.py
ADDED
pnbp/commands/subl.py
ADDED
pnbp/commands/tasks.py
ADDED
pnbp/helpers/__init__.py
ADDED
pnbp/helpers/wrappers.py
ADDED
pnbp/models/__init__.py
ADDED
pnbp/models/note.py
ADDED
pnbp/models/notebook.py
ADDED
pnbp/settings.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
from .models import Notebook, Note
|
|
2
|
+
from .settings import NotebookSettings, SettingsError
|
|
3
|
+
from pretty_notebook._internal.identities import IdentityError
|
|
4
|
+
from pretty_notebook._internal.search import SearchHit
|
|
5
|
+
from pretty_notebook._internal.navigation import NavigationHistory, NavigationIndex
|
|
6
|
+
|
|
7
|
+
__all__ = ["Notebook", "Note", "NotebookSettings", "SettingsError", "IdentityError", "SearchHit", "NavigationIndex", "NavigationHistory"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Private implementation helpers; use the public package API."""
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Reproducible Linux VPS bundles and bounded local deployment diagnostics."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import shlex
|
|
7
|
+
import subprocess
|
|
8
|
+
from dataclasses import asdict, dataclass
|
|
9
|
+
from importlib.resources import files
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from urllib.request import Request, urlopen
|
|
12
|
+
|
|
13
|
+
from .routes import validate_prefix
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class DeploySpec:
|
|
18
|
+
revision: str
|
|
19
|
+
domain: str
|
|
20
|
+
checkout: Path = Path('/opt/pretty-notebook')
|
|
21
|
+
data_dir: Path = Path('/var/lib/pretty-notebook')
|
|
22
|
+
env_file: Path = Path('/etc/pretty-notebook/environment')
|
|
23
|
+
backup_dir: Path = Path('/var/backups/pretty-notebook')
|
|
24
|
+
tls_cert: Path = Path('/etc/ssl/pretty-notebook/fullchain.pem')
|
|
25
|
+
tls_key: Path = Path('/etc/ssl/pretty-notebook/privkey.pem')
|
|
26
|
+
service: str = 'pretty-notebook'
|
|
27
|
+
user: str = 'pretty-notebook'
|
|
28
|
+
python: str = 'python3.14'
|
|
29
|
+
port: int = 8000
|
|
30
|
+
workers: int = 4
|
|
31
|
+
prefix: str = ''
|
|
32
|
+
|
|
33
|
+
def __post_init__(self):
|
|
34
|
+
if not re.fullmatch(r'(?:[0-9a-f]{40}|[0-9a-f]{64})', self.revision):
|
|
35
|
+
raise ValueError('Revision must be a full lowercase commit SHA.')
|
|
36
|
+
labels = self.domain.split('.')
|
|
37
|
+
if len(self.domain) > 253 or len(labels) < 2 or any(not re.fullmatch(r'[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?', label) for label in labels):
|
|
38
|
+
raise ValueError('Domain must be a lowercase DNS hostname without a scheme or port.')
|
|
39
|
+
for name in ('service', 'user'):
|
|
40
|
+
if not re.fullmatch(r'[a-z_][a-z0-9_-]{0,31}', getattr(self, name)):
|
|
41
|
+
raise ValueError(f'Invalid deployment {name}.')
|
|
42
|
+
if self.user == 'root':
|
|
43
|
+
raise ValueError('Use a dedicated non-root service account.')
|
|
44
|
+
for name in ('checkout', 'data_dir', 'env_file', 'backup_dir', 'tls_cert', 'tls_key'):
|
|
45
|
+
value = Path(getattr(self, name))
|
|
46
|
+
if not value.is_absolute() or len(value.parts) < 3 or '..' in value.parts or not re.fullmatch(r'/[A-Za-z0-9/_.-]+', str(value)):
|
|
47
|
+
raise ValueError(f'{name} must be an absolute path without shell/systemd metacharacters.')
|
|
48
|
+
object.__setattr__(self, name, value)
|
|
49
|
+
directories = (self.checkout, self.data_dir, self.backup_dir)
|
|
50
|
+
if any(a.is_relative_to(b) or b.is_relative_to(a) for i, a in enumerate(directories) for b in directories[i + 1:]):
|
|
51
|
+
raise ValueError('Checkout, data and backup directories must be separate.')
|
|
52
|
+
if any(self.env_file.is_relative_to(path) for path in directories):
|
|
53
|
+
raise ValueError('Environment file must be separate from code, data and backups.')
|
|
54
|
+
if self.python not in ('python3.11', 'python3.14'):
|
|
55
|
+
raise ValueError('Use supported Python 3.11 or 3.14.')
|
|
56
|
+
if type(self.port) is not int or not 1024 <= self.port <= 65535:
|
|
57
|
+
raise ValueError('Port must be from 1024 to 65535.')
|
|
58
|
+
if type(self.workers) is not int or not 1 <= self.workers <= 4:
|
|
59
|
+
raise ValueError('Workers must be from 1 to 4.')
|
|
60
|
+
validate_prefix(self.prefix)
|
|
61
|
+
|
|
62
|
+
def to_dict(self):
|
|
63
|
+
return {key: str(value) if isinstance(value, Path) else value for key, value in asdict(self).items()}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def bundle(spec, operation='init'):
|
|
67
|
+
if operation not in ('init', 'upgrade'):
|
|
68
|
+
raise ValueError('Unknown deployment operation.')
|
|
69
|
+
values = {key.upper(): str(value) for key, value in spec.to_dict().items()}
|
|
70
|
+
values.update({key + '_SH': shlex.quote(value) for key, value in list(values.items())})
|
|
71
|
+
values['PROXY'] = f''' proxy_pass http://127.0.0.1:{spec.port};
|
|
72
|
+
proxy_http_version 1.1;
|
|
73
|
+
proxy_set_header Host $host;
|
|
74
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
75
|
+
proxy_set_header X-Forwarded-For $remote_addr;
|
|
76
|
+
proxy_connect_timeout 5s;
|
|
77
|
+
proxy_read_timeout 35s;
|
|
78
|
+
proxy_send_timeout 35s;'''
|
|
79
|
+
values['PREFIX_REDIRECT'] = f' location = {spec.prefix} {{ return 308 {spec.prefix}/; }}' if spec.prefix else ''
|
|
80
|
+
resources = files('pretty_notebook').joinpath('resources/deploy')
|
|
81
|
+
result = {}
|
|
82
|
+
for name in ('service', 'nginx.conf', 'environment.example', 'common.sh', 'init.sh', 'upgrade.sh', 'rollback.sh', 'checkpoint.py'):
|
|
83
|
+
template = resources.joinpath(name + '.tmpl').read_text(encoding='utf-8')
|
|
84
|
+
rendered = re.sub(r'@([A-Z_]+)@', lambda match: values[match[1]], template)
|
|
85
|
+
result[spec.service + '.service' if name == 'service' else name] = rendered
|
|
86
|
+
manifest = {'format': 1, 'operation': operation, 'spec': spec.to_dict(),
|
|
87
|
+
'files': {name: hashlib.sha256(content.encode()).hexdigest() for name, content in result.items()},
|
|
88
|
+
'apply': f'sudo bash {operation}.sh',
|
|
89
|
+
'requirements': ['Linux x86_64, systemd, nginx, git, curl and the selected Python',
|
|
90
|
+
'An existing TLS certificate/key and a private configured environment file']}
|
|
91
|
+
result['plan.json'] = json.dumps(manifest, indent=2, sort_keys=True) + '\n'
|
|
92
|
+
return result
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def write_bundle(spec, output, *, operation='init'):
|
|
96
|
+
output = Path(output).expanduser()
|
|
97
|
+
if output.is_symlink():
|
|
98
|
+
raise ValueError('Bundle directory must not be a symlink.')
|
|
99
|
+
output = output.resolve()
|
|
100
|
+
contents = bundle(spec, operation)
|
|
101
|
+
for name, content in contents.items():
|
|
102
|
+
path = output / name
|
|
103
|
+
if path.is_symlink() or (path.exists() and (not path.is_file() or path.read_text() != content)):
|
|
104
|
+
raise ValueError(f'{name} already differs; choose an empty output directory.')
|
|
105
|
+
output.mkdir(parents=True, mode=0o700, exist_ok=True)
|
|
106
|
+
for name, content in contents.items():
|
|
107
|
+
path = output / name
|
|
108
|
+
if not path.exists():
|
|
109
|
+
with path.open('x', encoding='utf-8') as target:
|
|
110
|
+
target.write(content)
|
|
111
|
+
path.chmod(0o700 if name.endswith('.sh') else 0o600)
|
|
112
|
+
return {'output': str(output), 'operation': operation, 'revision': spec.revision,
|
|
113
|
+
'apply': f'sudo bash {shlex.quote(str(output / (operation + ".sh")))}'}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _run_checked(arguments, *, cwd=None, label):
|
|
117
|
+
try:
|
|
118
|
+
result = subprocess.run(arguments, cwd=cwd, capture_output=True, text=True, timeout=30)
|
|
119
|
+
except (OSError, subprocess.TimeoutExpired) as error:
|
|
120
|
+
raise ValueError(f'{label} could not run.') from error
|
|
121
|
+
if result.returncode:
|
|
122
|
+
raise ValueError(f'{label} failed; inspect it locally without sharing credentials.')
|
|
123
|
+
return result.stdout.strip()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def check_environment(spec, *, require_bootstrap=False):
|
|
127
|
+
path = spec.env_file
|
|
128
|
+
if path.is_symlink() or not path.is_file() or path.stat().st_mode & 0o077:
|
|
129
|
+
raise ValueError('Environment file must be a private regular file (mode 0600).')
|
|
130
|
+
values = {}
|
|
131
|
+
for line in path.read_text(encoding='utf-8').splitlines():
|
|
132
|
+
line = line.strip()
|
|
133
|
+
if not line or line.startswith('#'): continue
|
|
134
|
+
match = re.fullmatch(r'([A-Z_][A-Z0-9_]*)=(.*)', line)
|
|
135
|
+
if not match or match[1] in values:
|
|
136
|
+
raise ValueError('Environment file contains an invalid or duplicate assignment.')
|
|
137
|
+
try: parsed = shlex.split(match[2])
|
|
138
|
+
except ValueError as error: raise ValueError('Environment file contains invalid quoting.') from error
|
|
139
|
+
if len(parsed) > 1:
|
|
140
|
+
raise ValueError('Environment values containing spaces must be quoted.')
|
|
141
|
+
values[match[1]] = parsed[0] if parsed else ''
|
|
142
|
+
for key, expected in {'PNBP_DATA_DIR': str(spec.data_dir),
|
|
143
|
+
'PNBP_DATABASE_URL': f'sqlite://{spec.data_dir / "db.sqlite3"}',
|
|
144
|
+
'PNBP_URL_PREFIX': spec.prefix, 'PNBP_ASSET_MODE': 'local', 'JWT_ALGO': 'HS256'}.items():
|
|
145
|
+
if values.get(key, '') != expected: raise ValueError(f'Environment {key} does not match the deployment plan.')
|
|
146
|
+
hosts = values.get('PNBP_ALLOWED_HOSTS', '').split(',')
|
|
147
|
+
if spec.domain not in hosts or '*' in hosts:
|
|
148
|
+
raise ValueError('Environment PNBP_ALLOWED_HOSTS must include the planned domain without a wildcard.')
|
|
149
|
+
for key in ('JWT_SECRET', 'PNBP_BOOTSTRAP_TOKEN'):
|
|
150
|
+
value = values.get(key, '')
|
|
151
|
+
required = key == 'JWT_SECRET' or require_bootstrap or bool(value)
|
|
152
|
+
if required and (len(value.encode()) < 32 or value.startswith('REPLACE_')):
|
|
153
|
+
raise ValueError(f'Environment {key} requires a configured secret of at least 32 UTF-8 bytes.')
|
|
154
|
+
return {'environment': 'ok'}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def check_deployment(spec, *, health=False, require_bootstrap=False):
|
|
158
|
+
result = check_environment(spec, require_bootstrap=require_bootstrap)
|
|
159
|
+
import pwd
|
|
160
|
+
try:
|
|
161
|
+
owner = pwd.getpwnam(spec.user).pw_uid
|
|
162
|
+
except KeyError as error:
|
|
163
|
+
raise ValueError('The planned service account does not exist.') from error
|
|
164
|
+
state = spec.data_dir
|
|
165
|
+
if owner == 0 or state.is_symlink() or not state.is_dir() or state.stat().st_mode & 0o077 or state.stat().st_uid != owner:
|
|
166
|
+
raise ValueError('Persistent data must be a private directory owned by the non-root service account.')
|
|
167
|
+
revision = _run_checked(['git', '-C', str(spec.checkout), 'rev-parse', 'HEAD'], label='Checkout revision check')
|
|
168
|
+
if revision != spec.revision: raise ValueError('Checkout does not match the pinned revision.')
|
|
169
|
+
_run_checked(['git', '-C', str(spec.checkout), 'diff', '--exit-code'], label='Checkout cleanliness check')
|
|
170
|
+
python = str(spec.checkout / '.venv/bin/python')
|
|
171
|
+
version = _run_checked([python, '-c', 'import sys; print("%s.%s" % sys.version_info[:2])'], label='Python version check')
|
|
172
|
+
if version != spec.python.removeprefix('python'): raise ValueError('Virtual environment uses a different Python version.')
|
|
173
|
+
_run_checked([python, '-m', 'pip', 'check'], label='Dependency check')
|
|
174
|
+
_run_checked([python, 'install_assets.py', '--check'], cwd=spec.checkout / 'apps/web', label='Local asset check')
|
|
175
|
+
result.update(revision=revision, dependencies='ok', assets='ok', python=version)
|
|
176
|
+
if health:
|
|
177
|
+
try:
|
|
178
|
+
request = Request(f'http://127.0.0.1:{spec.port}/healthz', headers={'Host': spec.domain})
|
|
179
|
+
with urlopen(request, timeout=5) as response:
|
|
180
|
+
if json.loads(response.read(4096)) != {'status': 'ok'}: raise ValueError('Invalid health response.')
|
|
181
|
+
except (OSError, ValueError) as error:
|
|
182
|
+
raise ValueError('Loopback health check failed.') from error
|
|
183
|
+
result['health'] = 'ok'
|
|
184
|
+
return result
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Small project-owned SVGs for templates and generated notebook links."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from html import escape
|
|
5
|
+
|
|
6
|
+
_SHAPES = {
|
|
7
|
+
"menu": '<path d="M3 6h18M3 12h18M3 18h18"/>',
|
|
8
|
+
"sun": '<circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M2 12h2m16 0h2M5 5l1.5 1.5m11 11L19 19M5 19l1.5-1.5m11-11L19 5"/>',
|
|
9
|
+
"moon": '<path d="M20 14.5A8.5 8.5 0 0 1 9.5 4 8.5 8.5 0 1 0 20 14.5Z"/>',
|
|
10
|
+
"book": '<path d="M12 5v15m0-15C9 3 5 3 2 4v15c3-1 7-1 10 1 3-2 7-2 10-1V4c-3-1-7-1-10 1Z"/>',
|
|
11
|
+
"globe": '<circle cx="12" cy="12" r="9"/><ellipse cx="12" cy="12" rx="4" ry="9"/><path d="M3 12h18"/>',
|
|
12
|
+
"external": '<path d="M14 3h7v7m0-7L11 13M10 5H4a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-6"/>',
|
|
13
|
+
}
|
|
14
|
+
_SIZES = {"sm": 14, "md": 20, "lg": 24}
|
|
15
|
+
_LEGACY = {"eye": "moon", "eye-fill": "sun", "book-fill": "book",
|
|
16
|
+
"globe2": "globe", "box-arrow-up-right": "external"}
|
|
17
|
+
_LEGACY_MARKUP = re.compile(
|
|
18
|
+
r"<(?P<literal>pre|code|script|style)\b[^>]*>.*?</(?P=literal)\s*>"
|
|
19
|
+
r"|<i\s+class=(?P<quote>['\"])bi bi-(?P<name>[\w-]+)(?P=quote)"
|
|
20
|
+
r"(?:\s+style=['\"]font-size:10px;['\"])?\s*>\s*</i\s*>",
|
|
21
|
+
re.IGNORECASE | re.DOTALL,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def render_icon(name, *, label=None, size="md", variant="default"):
|
|
26
|
+
"""Return an inline SVG; adjacent text normally supplies its accessible name."""
|
|
27
|
+
if name not in _SHAPES or size not in _SIZES or variant not in {"default", "muted"}:
|
|
28
|
+
raise ValueError("Choose a supported icon, size (sm/md/lg), and variant (default/muted).")
|
|
29
|
+
accessibility = ('aria-hidden="true"' if label is None else
|
|
30
|
+
f'role="img" aria-label="{escape(label, quote=True)}"')
|
|
31
|
+
return (
|
|
32
|
+
f'<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-{size} icon-{variant}" '
|
|
33
|
+
f'width="{_SIZES[size]}" height="{_SIZES[size]}" viewBox="0 0 24 24" '
|
|
34
|
+
f'{accessibility} focusable="false" fill="none" stroke="currentColor" '
|
|
35
|
+
f'stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">'
|
|
36
|
+
f'{_SHAPES[name]}</svg>'
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def replace_legacy_icons(markup):
|
|
41
|
+
"""Display the five historical empty icon elements without rewriting storage.
|
|
42
|
+
|
|
43
|
+
Leave literal code, script/style blocks, unknown icons, and other HTML intact.
|
|
44
|
+
"""
|
|
45
|
+
def replace(match):
|
|
46
|
+
name = _LEGACY.get((match.group("name") or "").lower())
|
|
47
|
+
if name is None:
|
|
48
|
+
return match.group(0)
|
|
49
|
+
label = {"book": "Notebook", "globe": "Published notebook"}.get(name)
|
|
50
|
+
return render_icon(name, label=label, size="sm" if name == "external" else "md")
|
|
51
|
+
return _LEGACY_MARKUP.sub(replace, markup)
|