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 ADDED
@@ -0,0 +1,3 @@
1
+ """Sardine CMS command line interface."""
2
+
3
+ __version__ = "0.1.0"
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)