sardine-cms-cli 0.1.0__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.
- cms_cli/__init__.py +3 -0
- cms_cli/app.py +227 -0
- cms_cli/project.py +85 -0
- cms_cli/seed.py +133 -0
- cms_cli/seed_data.py +1804 -0
- cms_cli/templates/init/copier.yml +16 -0
- cms_cli/templates/init/template/.copier-answers.yml.jinja +2 -0
- cms_cli/templates/init/template/.gitignore +2 -0
- cms_cli/templates/init/template/README.md.jinja +15 -0
- cms_cli/templates/init/template/sardine.toml.jinja +12 -0
- sardine_cms_cli-0.1.0.dist-info/METADATA +25 -0
- sardine_cms_cli-0.1.0.dist-info/RECORD +14 -0
- sardine_cms_cli-0.1.0.dist-info/WHEEL +4 -0
- sardine_cms_cli-0.1.0.dist-info/entry_points.txt +2 -0
cms_cli/__init__.py
ADDED
cms_cli/app.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""The `cms` command line: thin wiring from project config to services."""
|
|
2
|
+
|
|
3
|
+
import http.server
|
|
4
|
+
from functools import partial
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from cms_build import build_site, create_target, create_theme
|
|
10
|
+
from cms_build.builder import Artifact
|
|
11
|
+
from cms_validation import Report, RuleSet, ValidationContext, default_ruleset
|
|
12
|
+
|
|
13
|
+
from cms_cli.project import PROJECT_FILE, Project, load_project
|
|
14
|
+
from cms_cli.seed import seed
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(add_completion=False, no_args_is_help=True)
|
|
17
|
+
|
|
18
|
+
ProjectDir = Annotated[
|
|
19
|
+
Path,
|
|
20
|
+
typer.Option("--project", "-p", help="Project directory containing sardine.toml"),
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _project(directory: Path) -> Project:
|
|
25
|
+
try:
|
|
26
|
+
return load_project(directory.resolve())
|
|
27
|
+
except FileNotFoundError as error:
|
|
28
|
+
typer.echo(f"error: {error}", err=True)
|
|
29
|
+
raise typer.Exit(code=2) from error
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _validate(project: Project) -> Report:
|
|
33
|
+
content = project.load_content()
|
|
34
|
+
ruleset = RuleSet(rules=default_ruleset())
|
|
35
|
+
context = ValidationContext(
|
|
36
|
+
required_languages=project.site.languages,
|
|
37
|
+
known_categories=(
|
|
38
|
+
tuple(sorted(project.site.categories)) if project.site.categories else None
|
|
39
|
+
),
|
|
40
|
+
)
|
|
41
|
+
return ruleset.run(content, context)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _report(report: Report) -> None:
|
|
45
|
+
for issue in report.issues:
|
|
46
|
+
typer.echo(str(issue))
|
|
47
|
+
typer.echo(f"{len(report.errors)} error(s), {len(report.warnings)} warning(s)")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _write_artifact(artifact: Artifact, output: Path) -> int:
|
|
51
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
root = output.resolve()
|
|
53
|
+
for path in artifact.paths():
|
|
54
|
+
destination = (root / path).resolve()
|
|
55
|
+
if not destination.is_relative_to(root):
|
|
56
|
+
raise ValueError(f"artifact path escapes the output directory: {path}")
|
|
57
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
destination.write_bytes(artifact.files[path])
|
|
59
|
+
return len(artifact.paths())
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@app.command()
|
|
63
|
+
def init(
|
|
64
|
+
directory: Annotated[Path, typer.Argument(help="Directory for the new project")],
|
|
65
|
+
name: Annotated[str, typer.Option(help="Site name")] = "My Sardine CMS",
|
|
66
|
+
base_url: Annotated[str, typer.Option(help="Canonical base URL")] = "https://example.com",
|
|
67
|
+
languages: Annotated[
|
|
68
|
+
str, typer.Option(help="Required target languages, comma-separated")
|
|
69
|
+
] = "pt-pt, es, fr, de",
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Scaffold a new project from the built-in Copier template."""
|
|
72
|
+
from copier import run_copy
|
|
73
|
+
|
|
74
|
+
if (directory / PROJECT_FILE).exists():
|
|
75
|
+
typer.echo(f"error: {directory} already contains {PROJECT_FILE}", err=True)
|
|
76
|
+
raise typer.Exit(code=2)
|
|
77
|
+
template = Path(__file__).parent / "templates" / "init"
|
|
78
|
+
run_copy(
|
|
79
|
+
str(template),
|
|
80
|
+
str(directory),
|
|
81
|
+
data={"project_name": name, "base_url": base_url, "languages": languages},
|
|
82
|
+
defaults=True,
|
|
83
|
+
quiet=True,
|
|
84
|
+
)
|
|
85
|
+
typer.echo(f"created {directory / PROJECT_FILE} — next: cms seed -p {directory}")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@app.command(name="seed")
|
|
89
|
+
def seed_command(
|
|
90
|
+
project_dir: ProjectDir = Path(),
|
|
91
|
+
force: Annotated[
|
|
92
|
+
bool, typer.Option("--force", help="Overwrite content that already exists")
|
|
93
|
+
] = False,
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Create fictional starter content in the project storage."""
|
|
96
|
+
project = _project(project_dir)
|
|
97
|
+
with project.open_storage() as storage:
|
|
98
|
+
if storage.has_content() and not force:
|
|
99
|
+
typer.echo(
|
|
100
|
+
"error: the project storage already has content; "
|
|
101
|
+
"seeding would overwrite it (use --force to do so anyway)",
|
|
102
|
+
err=True,
|
|
103
|
+
)
|
|
104
|
+
raise typer.Exit(code=3)
|
|
105
|
+
pages, articles, media = seed(storage, project.directory)
|
|
106
|
+
typer.echo(f"seeded {pages} page(s), {articles} article(s) and {media} media asset(s)")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@app.command()
|
|
110
|
+
def validate(project_dir: ProjectDir = Path()) -> None:
|
|
111
|
+
"""Run the validation rules; exits non-zero when errors exist."""
|
|
112
|
+
project = _project(project_dir)
|
|
113
|
+
report = _validate(project)
|
|
114
|
+
_report(report)
|
|
115
|
+
if not report.ok:
|
|
116
|
+
raise typer.Exit(code=1)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _build_artifact(project: Project) -> Artifact:
|
|
120
|
+
theme = create_theme(project.site.theme, overrides=project.theme_overrides)
|
|
121
|
+
return build_site(
|
|
122
|
+
project.site,
|
|
123
|
+
project.load_content(),
|
|
124
|
+
theme=theme,
|
|
125
|
+
media_files=project.collect_media_files(),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@app.command()
|
|
130
|
+
def build(project_dir: ProjectDir = Path()) -> None:
|
|
131
|
+
"""Validate, then produce the deterministic static build."""
|
|
132
|
+
project = _project(project_dir)
|
|
133
|
+
report = _validate(project)
|
|
134
|
+
if not report.ok:
|
|
135
|
+
_report(report)
|
|
136
|
+
raise typer.Exit(code=1)
|
|
137
|
+
artifact = _build_artifact(project)
|
|
138
|
+
written = _write_artifact(artifact, project.output)
|
|
139
|
+
typer.echo(f"built {written} file(s) into {project.output} (digest {artifact.digest()[:12]})")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@app.command()
|
|
143
|
+
def export(
|
|
144
|
+
project_dir: ProjectDir = Path(),
|
|
145
|
+
target: Annotated[str, typer.Option(help="Deployment target adapter")] = "generic",
|
|
146
|
+
) -> None:
|
|
147
|
+
"""Build plus the deployment target's configuration files."""
|
|
148
|
+
project = _project(project_dir)
|
|
149
|
+
report = _validate(project)
|
|
150
|
+
if not report.ok:
|
|
151
|
+
_report(report)
|
|
152
|
+
raise typer.Exit(code=1)
|
|
153
|
+
artifact = _build_artifact(project)
|
|
154
|
+
adapter = create_target(target)
|
|
155
|
+
for path, data in sorted(adapter.extra_files(project.site, artifact).items()):
|
|
156
|
+
artifact.add(path, data)
|
|
157
|
+
written = _write_artifact(artifact, project.output)
|
|
158
|
+
typer.echo(f"exported {written} file(s) for target {adapter.name} into {project.output}")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@app.command()
|
|
162
|
+
def preview(
|
|
163
|
+
project_dir: ProjectDir = Path(),
|
|
164
|
+
port: Annotated[int, typer.Option(help="Port to serve on")] = 8000,
|
|
165
|
+
) -> None:
|
|
166
|
+
"""Serve the built site locally (build first)."""
|
|
167
|
+
project = _project(project_dir)
|
|
168
|
+
if not project.output.is_dir():
|
|
169
|
+
typer.echo("error: output directory missing — run `cms build` first", err=True)
|
|
170
|
+
raise typer.Exit(code=2)
|
|
171
|
+
handler = partial(http.server.SimpleHTTPRequestHandler, directory=str(project.output))
|
|
172
|
+
typer.echo(f"serving {project.output} at http://127.0.0.1:{port}/ (Ctrl+C to stop)")
|
|
173
|
+
with http.server.ThreadingHTTPServer(("127.0.0.1", port), handler) as server:
|
|
174
|
+
server.serve_forever()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
admin_app = typer.Typer(add_completion=False, no_args_is_help=True)
|
|
178
|
+
app.add_typer(admin_app, name="admin", help="Admin panel operations")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@admin_app.command(name="create-user")
|
|
182
|
+
def admin_create_user(
|
|
183
|
+
username: Annotated[str, typer.Argument(help="Account username (lowercase)")],
|
|
184
|
+
project_dir: ProjectDir = Path(),
|
|
185
|
+
role: Annotated[str, typer.Option(help="editor | reviewer | publisher | admin")] = "editor",
|
|
186
|
+
password: Annotated[
|
|
187
|
+
str,
|
|
188
|
+
typer.Option(prompt=True, confirmation_prompt=True, hide_input=True, help="Prompted"),
|
|
189
|
+
] = "",
|
|
190
|
+
force: Annotated[
|
|
191
|
+
bool, typer.Option("--force", help="Replace the account if it already exists")
|
|
192
|
+
] = False,
|
|
193
|
+
) -> None:
|
|
194
|
+
"""Create an admin account (there are no default credentials)."""
|
|
195
|
+
from datetime import UTC, datetime
|
|
196
|
+
|
|
197
|
+
from cms_core.accounts import Role, User
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
from cms_admin.security import hash_password
|
|
201
|
+
except ImportError as error:
|
|
202
|
+
typer.echo("error: the admin package is not installed (pip install cms-admin)", err=True)
|
|
203
|
+
raise typer.Exit(code=2) from error
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
account_role = Role(role)
|
|
207
|
+
except ValueError as error:
|
|
208
|
+
typer.echo(f"error: unknown role {role!r} (editor|reviewer|publisher|admin)", err=True)
|
|
209
|
+
raise typer.Exit(code=2) from error
|
|
210
|
+
project = _project(project_dir)
|
|
211
|
+
with project.open_storage() as storage:
|
|
212
|
+
if storage.load_user(username) is not None and not force:
|
|
213
|
+
typer.echo(f"error: user {username!r} already exists (use --force)", err=True)
|
|
214
|
+
raise typer.Exit(code=3)
|
|
215
|
+
storage.save_user(
|
|
216
|
+
User(
|
|
217
|
+
username=username,
|
|
218
|
+
password_hash=hash_password(password),
|
|
219
|
+
role=account_role,
|
|
220
|
+
created_at=datetime.now(UTC),
|
|
221
|
+
)
|
|
222
|
+
)
|
|
223
|
+
typer.echo(f"created user {username!r} with role {account_role.value}")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def main() -> None:
|
|
227
|
+
app()
|
cms_cli/project.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Project configuration: `sardine.toml` loading and content access."""
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from cms_build import SiteConfig
|
|
8
|
+
from cms_core import Language
|
|
9
|
+
from cms_core.storage import StorageBackend, create_storage
|
|
10
|
+
from cms_validation import SiteContent
|
|
11
|
+
|
|
12
|
+
PROJECT_FILE = "sardine.toml"
|
|
13
|
+
LEGACY_PROJECT_FILE = "stillsite.toml"
|
|
14
|
+
"""Pre-rename projects keep working: read the old file when the new one
|
|
15
|
+
is absent (the product was renamed from Stillsite to Sardine CMS)."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class Project:
|
|
20
|
+
directory: Path
|
|
21
|
+
site: SiteConfig
|
|
22
|
+
storage_url: str
|
|
23
|
+
output: Path
|
|
24
|
+
|
|
25
|
+
def open_storage(self) -> StorageBackend:
|
|
26
|
+
return create_storage(self.storage_url)
|
|
27
|
+
|
|
28
|
+
def load_content(self) -> SiteContent:
|
|
29
|
+
with self.open_storage() as storage:
|
|
30
|
+
return SiteContent(
|
|
31
|
+
articles=storage.load_all_articles(),
|
|
32
|
+
pages=storage.load_all_pages(),
|
|
33
|
+
media=storage.load_all_media_assets(),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def theme_overrides(self) -> Path | None:
|
|
38
|
+
overrides = self.directory / "theme"
|
|
39
|
+
return overrides if overrides.is_dir() else None
|
|
40
|
+
|
|
41
|
+
def collect_media_files(self) -> dict[str, bytes]:
|
|
42
|
+
media_dir = self.directory / "media"
|
|
43
|
+
if not media_dir.is_dir():
|
|
44
|
+
return {}
|
|
45
|
+
return {
|
|
46
|
+
path.relative_to(media_dir).as_posix(): path.read_bytes()
|
|
47
|
+
for path in sorted(media_dir.rglob("*"))
|
|
48
|
+
if path.is_file() and not path.name.startswith(".")
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def load_project(directory: Path) -> Project:
|
|
53
|
+
config_path = directory / PROJECT_FILE
|
|
54
|
+
if not config_path.is_file():
|
|
55
|
+
legacy = directory / LEGACY_PROJECT_FILE
|
|
56
|
+
if legacy.is_file():
|
|
57
|
+
config_path = legacy
|
|
58
|
+
else:
|
|
59
|
+
raise FileNotFoundError(f"{PROJECT_FILE} not found in {directory}")
|
|
60
|
+
data = tomllib.loads(config_path.read_text(encoding="utf-8"))
|
|
61
|
+
|
|
62
|
+
site_data = data.get("site", {})
|
|
63
|
+
site = SiteConfig(
|
|
64
|
+
name=site_data["name"],
|
|
65
|
+
base_url=site_data["base_url"],
|
|
66
|
+
languages=tuple(Language(code) for code in site_data.get("languages", [])),
|
|
67
|
+
blog_path=site_data.get("blog_path", "blog"),
|
|
68
|
+
theme=site_data.get("theme", "default"),
|
|
69
|
+
page_size=site_data.get("page_size", 10),
|
|
70
|
+
categories=site_data.get("categories", {}),
|
|
71
|
+
labels=site_data.get("labels", {}),
|
|
72
|
+
organization=site_data.get("organization"),
|
|
73
|
+
footer_text=site_data.get("footer_text"),
|
|
74
|
+
admin_url=site_data.get("admin_url"),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
storage_url = data.get("storage", {}).get("url", "sqlite:///content.sqlite3")
|
|
78
|
+
if storage_url.startswith("sqlite:///"):
|
|
79
|
+
# Relative SQLite paths are relative to the project directory.
|
|
80
|
+
raw_path = Path(storage_url.removeprefix("sqlite:///").lstrip("/"))
|
|
81
|
+
resolved = raw_path if raw_path.is_absolute() else directory / raw_path
|
|
82
|
+
storage_url = f"sqlite:///{resolved}"
|
|
83
|
+
|
|
84
|
+
output = directory / data.get("build", {}).get("output", "_site")
|
|
85
|
+
return Project(directory=directory, site=site, storage_url=storage_url, output=output)
|
cms_cli/seed.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Seed logic: writes the fictional starter content (see seed_data).
|
|
2
|
+
|
|
3
|
+
Fixed timestamps keep seeded projects building deterministically. When a
|
|
4
|
+
project directory is given, the referenced media files are written too, so a
|
|
5
|
+
freshly scaffolded project builds with no broken references.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from datetime import timedelta
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from cms_core import (
|
|
12
|
+
Article,
|
|
13
|
+
ArticleContent,
|
|
14
|
+
ContentStatus,
|
|
15
|
+
Language,
|
|
16
|
+
MediaAsset,
|
|
17
|
+
Page,
|
|
18
|
+
PageContent,
|
|
19
|
+
Section,
|
|
20
|
+
SectionContent,
|
|
21
|
+
new_article,
|
|
22
|
+
new_page,
|
|
23
|
+
)
|
|
24
|
+
from cms_core.storage import StorageBackend
|
|
25
|
+
|
|
26
|
+
from cms_cli.seed_data import (
|
|
27
|
+
ABOUT,
|
|
28
|
+
ABOUT_STORY,
|
|
29
|
+
ARTICLES,
|
|
30
|
+
COVER_ALT,
|
|
31
|
+
COVER_SVGS,
|
|
32
|
+
HOME,
|
|
33
|
+
HOME_ABOUT,
|
|
34
|
+
HOME_CTA,
|
|
35
|
+
HOME_EXPERTISE,
|
|
36
|
+
HOME_HERO,
|
|
37
|
+
HOME_LATEST,
|
|
38
|
+
MEDIA_ALT,
|
|
39
|
+
ROCKET_SVG,
|
|
40
|
+
SEED_TIME,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _page(
|
|
45
|
+
page_id: str,
|
|
46
|
+
contents: dict[Language, PageContent],
|
|
47
|
+
sections: list[tuple[str, str, dict[Language, SectionContent]]],
|
|
48
|
+
) -> Page:
|
|
49
|
+
page = new_page(page_id, contents[Language.EN], now=SEED_TIME)
|
|
50
|
+
for language, content in contents.items():
|
|
51
|
+
if language is not Language.EN:
|
|
52
|
+
page.set_translation(language, content)
|
|
53
|
+
for key, kind, bodies in sections:
|
|
54
|
+
section = Section(key=key, kind=kind, source=bodies[Language.EN])
|
|
55
|
+
for language, body in bodies.items():
|
|
56
|
+
if language is not Language.EN:
|
|
57
|
+
section.set_translation(language, body)
|
|
58
|
+
page.sections.append(section)
|
|
59
|
+
page.status = ContentStatus.PUBLISHED
|
|
60
|
+
return page
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _article(
|
|
64
|
+
article_id: str,
|
|
65
|
+
category: str,
|
|
66
|
+
tags: tuple[str, ...],
|
|
67
|
+
days: int,
|
|
68
|
+
contents: dict[Language, ArticleContent],
|
|
69
|
+
) -> Article:
|
|
70
|
+
article = new_article(article_id, contents[Language.EN], now=SEED_TIME + timedelta(days=days))
|
|
71
|
+
for language, content in contents.items():
|
|
72
|
+
if language is not Language.EN:
|
|
73
|
+
article.set_translation(language, content)
|
|
74
|
+
article.status = ContentStatus.PUBLISHED
|
|
75
|
+
article.category = category
|
|
76
|
+
article.tags = tags
|
|
77
|
+
return article
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def seed(storage: StorageBackend, project_dir: Path | None = None) -> tuple[int, int, int]:
|
|
81
|
+
"""Write the starter content; returns (pages, articles, media) counts."""
|
|
82
|
+
storage.save_page(
|
|
83
|
+
_page(
|
|
84
|
+
"home",
|
|
85
|
+
HOME,
|
|
86
|
+
[
|
|
87
|
+
("hero", "hero", HOME_HERO),
|
|
88
|
+
("about", "story", HOME_ABOUT),
|
|
89
|
+
("expertise", "expertise", HOME_EXPERTISE),
|
|
90
|
+
("latest", "latest-articles", HOME_LATEST),
|
|
91
|
+
("join", "contact", HOME_CTA),
|
|
92
|
+
],
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
storage.save_page(_page("about", ABOUT, [("story", "story", ABOUT_STORY)]))
|
|
96
|
+
|
|
97
|
+
for article_id, (category, tags, days, contents) in ARTICLES.items():
|
|
98
|
+
entry = _article(article_id, category, tags, days, contents)
|
|
99
|
+
entry.cover = f"cover-{category}"
|
|
100
|
+
storage.save_article(entry)
|
|
101
|
+
|
|
102
|
+
rocket = MediaAsset(
|
|
103
|
+
id="rocket",
|
|
104
|
+
path="images/rocket.svg",
|
|
105
|
+
mime_type="image/svg+xml",
|
|
106
|
+
width=1200,
|
|
107
|
+
height=675,
|
|
108
|
+
alt=dict(MEDIA_ALT),
|
|
109
|
+
)
|
|
110
|
+
storage.save_media_asset(rocket)
|
|
111
|
+
for cover_id, svg in COVER_SVGS.items():
|
|
112
|
+
storage.save_media_asset(
|
|
113
|
+
MediaAsset(
|
|
114
|
+
id=cover_id,
|
|
115
|
+
path=f"images/{cover_id}.svg",
|
|
116
|
+
mime_type="image/svg+xml",
|
|
117
|
+
width=1200,
|
|
118
|
+
height=630,
|
|
119
|
+
alt=dict(COVER_ALT[cover_id]),
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
if project_dir is not None:
|
|
123
|
+
cover_target = project_dir / "media" / "images" / f"{cover_id}.svg"
|
|
124
|
+
cover_target.parent.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
if not cover_target.exists():
|
|
126
|
+
cover_target.write_text(svg, encoding="utf-8")
|
|
127
|
+
if project_dir is not None:
|
|
128
|
+
target = project_dir / "media" / "images" / "rocket.svg"
|
|
129
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
if not target.exists():
|
|
131
|
+
target.write_text(ROCKET_SVG, encoding="utf-8")
|
|
132
|
+
|
|
133
|
+
return 2, len(ARTICLES), 1 + len(COVER_SVGS)
|