Compare commits

..

No commits in common. "main" and "v0.5.0" have entirely different histories.
main ... v0.5.0

9 changed files with 27 additions and 389 deletions

View file

@ -8,6 +8,6 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- uses: yesolutions/mirror-action@master - uses: yesolutions/mirror-action@master
with: with:
REMOTE: 'https://codeberg.org/kbenestad/mdcms.git' REMOTE: 'https://codeberg.org/mdcms/mdcms.git'
GIT_USERNAME: kbenestad GIT_USERNAME: kbenestad
GIT_PASSWORD: ${{ secrets.GIT_PASSWORD_CODEBERG }} GIT_PASSWORD: ${{ secrets.GIT_PASSWORD_CODEBERG }}

10
.gitignore vendored
View file

@ -1,13 +1,3 @@
### Python ###
__pycache__/
*.py[cod]
*.pyo
dist/
build/
*.egg-info/
.venv/
venv/
### AL ### ### AL ###
#Template for AL projects for Dynamics 365 Business Central #Template for AL projects for Dynamics 365 Business Central
#launch.json folder #launch.json folder

View file

@ -1,30 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Redirecting…</title>
<script>
// SPA routing for GitHub Pages: the server returns this 404 page for any path
// it can't resolve. We encode the intended path as ?_route= and redirect to the
// app root so index.html can pick it up and render the right page.
(function () {
var path = window.location.pathname;
var search = window.location.search;
var hash = window.location.hash;
// On GitHub Pages project sites the app lives at /repo-name/, so we keep
// that prefix and only encode the segment after it.
var parts = path.split('/');
var isGhPages = window.location.hostname.endsWith('.github.io') && parts.length > 2;
var base = isGhPages ? '/' + parts[1] + '/' : '/';
var route = '/' + parts.slice(isGhPages ? 2 : 1).join('/');
var qs = '_route=' + encodeURIComponent(route);
if (search) qs += '&' + search.slice(1);
window.location.replace(window.location.origin + base + '?' + qs + hash);
})();
</script>
</head>
<body></body>
</html>

View file

@ -1,5 +1,5 @@
# mdcms v0.6.0 | DO NOT REMOVE THIS COMMENT # mdcms v0.4 | DO NOT REMOVE THIS COMMENT
# MD-CMS v0.6.0 — Site configuration # MD-CMS v0.4 — Site configuration
# #
# Only `sitename` and `navigation` are required. Uncomment and edit the rest # Only `sitename` and `navigation` are required. Uncomment and edit the rest
# as needed. See https://kbenestad.codeberg.page/md-cms for the full reference. # as needed. See https://kbenestad.codeberg.page/md-cms for the full reference.

View file

@ -1,6 +1,6 @@
<!-- mdcms v0.6.0 | DO NOT REMOVE THIS COMMENT --> <!-- mdcms v0.4 | DO NOT REMOVE THIS COMMENT -->
<!-- <!--
MD-CMS v0.6.0 — Renderer MD-CMS v0.4 — Renderer
Copyright 2026 Kristian Benestad | kbenestad.codeberg.page Copyright 2026 Kristian Benestad | kbenestad.codeberg.page
@ -1082,22 +1082,6 @@ body {
'use strict'; 'use strict';
// ─── State ──────────────────────────────────────────────── // ─── State ────────────────────────────────────────────────
// Capture the intended pathname before anything mutates the URL.
// 404.html (GitHub Pages SPA routing) encodes the original path as ?_route=.
const _initialPathname = (() => {
const route = new URLSearchParams(window.location.search).get('_route');
if (route) {
const u = new URL(window.location);
u.searchParams.delete('_route');
window.history.replaceState(null, '', u);
return route;
}
return window.location.pathname;
})();
let basePath = '/'; // set by initBasePath() once nav data is loaded
let config = {}; let config = {};
let navData = []; let navData = [];
let navSections = []; let navSections = [];
@ -3038,34 +3022,6 @@ function fmtDatetime(dtStr) {
}); });
} }
// ─── Clean URL routing ────────────────────────────────────
// Returns the file (e.g. "pages/timesheet.md") if the given slug is a section-id
// that has a matching pages/{slug}.md entry in navData.
function resolveSlugToFile(slug) {
if (!slug || !navSections.some(s => s.code === slug)) return null;
const file = `pages/${slug}.md`;
return navData.some(p => p.file === file) ? file : null;
}
// Called once after navData/navSections are populated.
// Sets basePath (the app root) and returns the initial page file when the URL
// already contains a section-id slug (direct access via clean URL or 404 redirect).
function initBasePath() {
const segments = _initialPathname.split('/').filter(Boolean);
if (segments.length > 0) {
const lastSeg = segments[segments.length - 1];
const file = resolveSlugToFile(lastSeg);
if (file) {
const slugIdx = _initialPathname.lastIndexOf('/' + lastSeg);
basePath = _initialPathname.slice(0, slugIdx + 1) || '/';
return file;
}
}
basePath = _initialPathname.endsWith('/') ? _initialPathname : _initialPathname + '/';
return null;
}
// ─── Page loading ───────────────────────────────────────── // ─── Page loading ─────────────────────────────────────────
async function navigateTo(file) { async function navigateTo(file) {
currentPage = file; currentPage = file;
@ -3090,23 +3046,14 @@ function fmtDatetime(dtStr) {
} }
} }
// Build a clean URL. Pages whose filename matches a nav section-id get a clean // Build a clean URL: keep origin + path, set ?cat only when non-default, set hash to conceptual file
// pathname (e.g. /timesheet); all other pages keep the hash-based URL.
const u = new URL(window.location); const u = new URL(window.location);
if (categoriesUse && activeCategory && activeCategory !== defaultCategoryCode) { if (categoriesUse && activeCategory && activeCategory !== defaultCategoryCode) {
u.searchParams.set('cat', activeCategory); u.searchParams.set('cat', activeCategory);
} else { } else {
u.searchParams.delete('cat'); u.searchParams.delete('cat');
} }
const slugMatch = file.match(/^pages\/([^/]+)\.md$/); u.hash = '#' + file;
const slug = slugMatch ? slugMatch[1] : null;
if (slug && navSections.some(s => s.code === slug)) {
u.pathname = basePath + slug;
u.hash = '';
} else {
u.pathname = basePath;
u.hash = '#' + file;
}
window.history.replaceState(null, '', u); window.history.replaceState(null, '', u);
contentEl.innerHTML = '<div class="loading-spinner"></div>'; contentEl.innerHTML = '<div class="loading-spinner"></div>';
@ -3182,13 +3129,6 @@ function fmtDatetime(dtStr) {
if (page && page !== currentPage) navigateTo(page); if (page && page !== currentPage) navigateTo(page);
}); });
window.addEventListener('popstate', () => {
const slug = window.location.pathname.replace(basePath, '').replace(/^\//, '').replace(/\/$/, '');
const pathPage = slug ? resolveSlugToFile(slug) : null;
const page = pathPage || getPageFromHash();
if (page && page !== currentPage) navigateTo(page);
});
// ─── Scroll to top ─────────────────────────────────────── // ─── Scroll to top ───────────────────────────────────────
const scrollBtn = document.getElementById('scrollTop'); const scrollBtn = document.getElementById('scrollTop');
window.addEventListener('scroll', () => { window.addEventListener('scroll', () => {
@ -3257,7 +3197,6 @@ function fmtDatetime(dtStr) {
} }
renderNav(); renderNav();
} }
const routeFromPath = initBasePath();
if (config.search !== false) { if (config.search !== false) {
try { try {
@ -3286,7 +3225,7 @@ function fmtDatetime(dtStr) {
} }
const hashPage = getPageFromHash(); const hashPage = getPageFromHash();
await navigateTo(routeFromPath || hashPage || defaultPage()); await navigateTo(hashPage || defaultPage());
} catch (err) { } catch (err) {
document.getElementById('app').innerHTML = `<div style="max-width:600px;margin:4rem auto;padding:2rem;text-align:center;font-family:system-ui;"> document.getElementById('app').innerHTML = `<div style="max-width:600px;margin:4rem auto;padding:2rem;text-align:center;font-family:system-ui;">
<h1 style="color:#EF4444;font-size:1.5rem;">MD-CMS Error</h1> <h1 style="color:#EF4444;font-size:1.5rem;">MD-CMS Error</h1>

View file

@ -1,45 +0,0 @@
{
"mdcms": "0.4",
"files": [
"404.html",
"assets/icons/add.svg",
"assets/icons/arrow_drop_down.svg",
"assets/icons/arrow_right.svg",
"assets/icons/collapse_content.svg",
"assets/icons/dangerous.svg",
"assets/icons/dark_mode.svg",
"assets/icons/error.svg",
"assets/icons/exclamation.svg",
"assets/icons/expand_content.svg",
"assets/icons/history.svg",
"assets/icons/info.svg",
"assets/icons/keyboard_arrow_down.svg",
"assets/icons/keyboard_arrow_right.svg",
"assets/icons/keyboard_double_arrow_down.svg",
"assets/icons/keyboard_double_arrow_right.svg",
"assets/icons/language.svg",
"assets/icons/light_mode.svg",
"assets/icons/menu.svg",
"assets/icons/minimize.svg",
"assets/icons/mobile_arrow_down.svg",
"assets/icons/report.svg",
"assets/icons/search.svg",
"assets/icons/success.svg",
"assets/icons/text_compare.svg",
"assets/icons/warning.svg",
"assets/images/favicon.png",
"config.yml",
"index.html",
"nav.yml",
"pages/about.md",
"pages/docs.md",
"pages/home.md",
"pages/tabs-accordions.md",
"search.json",
"theme.yml"
],
"dirs": [
"assets/fonts",
"posts"
]
}

View file

@ -197,79 +197,6 @@ When a site uses category-suffixed page files (e.g. `page.current.md`) and is ho
--- ---
## Manifest-driven download and URL-based register (`mdcms.py`, `app/mdcms.json`)
`mdcms build` now writes `mdcms.json` to the site root on every build. `mdcms register` can accept a GitHub repo URL or a plain HTTPS URL as the source to download from.
### `mdcms build` writes `mdcms.json`
At the end of each build, `generate_site_manifest()` walks the site directory, lists every non-hidden file (excluding `mdcms.json` itself), records any empty directories, and writes `mdcms.json`. This file is deployed alongside the rest of the site — it is the machine-readable index of what the site contains.
Format:
```json
{
"mdcms": "0.4",
"files": ["index.html", "config.yml", "assets/icons/add.svg", ...],
"dirs": ["assets/fonts", "posts"]
}
```
`files` — all deployable files, paths relative to the site root.
`dirs` — empty directories to create on download (no file needed to keep them alive).
### `mdcms register` accepts URLs
`PATH` can now be a GitHub repo URL or a plain HTTPS URL pointing to a deployed mdcms site. A `--from URL` option is also available as an explicit override.
```
mdcms register mysite # existing behaviour
mdcms register mysite ./mydir # local path
mdcms register mysite https://github.com/owner/repo # GitHub repo
mdcms register mysite https://github.com/owner/repo/tree/main/subdir
mdcms register mysite --from https://example.com/mysite # deployed site
```
**GitHub URL** — tries `mdcms.json` from the raw content URL first; falls back to the GitHub Contents API tree-walk if no manifest is found.
**Plain HTTPS URL** — fetches `{url}/mdcms.json`; if not found, reports an error with guidance.
### `app/mdcms.json`
The starter template now ships with its own `mdcms.json`. This means `mdcms register mysite https://github.com/kbenestad/mdcms/tree/main/app` works via the manifest path with no API calls.
### `_http_get` / `_http_get_github`
`_http_get(url)` — generic SSL-verified GET, no vendor headers. Used for raw file downloads and manifest fetches.
`_http_get_github(url)` — adds `Accept: application/vnd.github.v3+json` for Contents API responses (only needed in the fallback tree-walk path).
---
## Clean URLs for section-id pages (`app/index.html`, `app/404.html`)
Pages whose filename matches a nav section-id can now be accessed at a clean URL path (e.g. `example.com/timesheet`) instead of the hash-based URL (`example.com/#pages/timesheet.md`).
### How it works
When you navigate to a page whose base filename (`timesheet`) matches a `code` entry in the `sections:` block of `nav.yml`, the renderer uses `history.replaceState` to rewrite the URL from `/#pages/timesheet.md` to `/timesheet`. All other pages continue to use hash-based URLs unchanged.
On startup, if the URL pathname already contains a section-id slug (because the user typed or was linked to `example.com/timesheet` directly), the renderer detects it, sets the correct base path, and loads the matching page.
Subpath deployments (e.g. `example.com/mysite/`) are handled automatically: the renderer determines the base from the initial pathname.
### 404.html for GitHub Pages
A new `app/404.html` file enables direct clean-URL access on GitHub Pages. When GitHub Pages serves the 404 page for an unknown path (e.g. `/timesheet`), `404.html` encodes the path as `?_route=/timesheet` and redirects to the app root. `index.html` reads `_route`, cleans up the URL, and routes to the right page. For other static hosts (Netlify, Cloudflare Pages, etc.) a `/*``/index.html` rewrite rule in the host's config achieves the same result.
### Condition
Only pages files that are both:
1. located in `pages/` with a name matching a section `code` in `nav.yml`, and
2. present in the `pages:` list in `nav.yml`
…get a clean URL. All other pages continue to use `#` routing.
---
## Fix: `config.yml` YAML parse errors now abort the build with a clear message ## Fix: `config.yml` YAML parse errors now abort the build with a clear message
A malformed `config.yml` (e.g. a stray tab character, which YAML forbids as a token starter) previously caused `read_config` to silently return an empty dict. The build would proceed with no config — categories disabled, no default category code — producing a broken `nav.yml` with wrong filenames and missing `variants` fields, so category-variant pages would not appear in the sidebar. A malformed `config.yml` (e.g. a stray tab character, which YAML forbids as a token starter) previously caused `read_config` to silently return an empty dict. The build would proceed with no config — categories disabled, no default category code — producing a broken `nav.yml` with wrong filenames and missing `variants` fields, so category-variant pages would not appear in the sidebar.

179
mdcms.py
View file

@ -1,6 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# #
# mdcms v0.6.0 — CLI companion # mdcms v0.4.0 — CLI companion
# #
# Copyright 2026 Kristian Benestad # Copyright 2026 Kristian Benestad
# #
@ -16,7 +16,7 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""MD-CMS v0.6.0 — CLI tool for managing and building MD-CMS sites.""" """MD-CMS v0.4.0 — CLI tool for managing and building MD-CMS sites."""
import json import json
import os import os
@ -32,21 +32,15 @@ import certifi
import click import click
import yaml import yaml
CLI_VERSION = "0.6.0" CLI_VERSION = "0.4.0"
CLI_RELEASE_DATE = "7 June 2026" CLI_RELEASE_DATE = "17 May 2026"
MIN_SUPPORTED_VERSION = "0.3" MIN_SUPPORTED_VERSION = "0.3"
MARKER_RE = re.compile(r"mdcms v(\d+\.\d+)", re.IGNORECASE) MARKER_RE = re.compile(r"mdcms v(\d+\.\d+)", re.IGNORECASE)
CATEGORY_CODE_RE = re.compile(r"^[a-zA-Z0-9\-]+$") CATEGORY_CODE_RE = re.compile(r"^[a-zA-Z0-9\-]+$")
REGISTRY_FILE = Path.home() / ".config" / "mdcms" / "sites.json" REGISTRY_FILE = Path.home() / ".config" / "mdcms" / "sites.json"
TEMPLATE_BASE_URL = "https://raw.githubusercontent.com/kbenestad/mdcms/main/app" GITHUB_CONTENTS_API = "https://api.github.com/repos/kbenestad/mdcms/contents/app"
MANIFEST_FILENAME = "mdcms.json"
GITHUB_URL_RE = re.compile(
r"https?://github\.com/([^/]+)/([^/]+?)(?:\.git)?"
r"(?:/tree/([^/]+?)(?:/(.+?))?)?/?$"
)
# ─── Version helpers ────────────────────────────────────────── # ─── Version helpers ──────────────────────────────────────────
@ -548,8 +542,6 @@ def run_build(site_path: Path):
fg="cyan", fg="cyan",
)) ))
generate_site_manifest(site_path)
# ─── PWA generation ─────────────────────────────────────────── # ─── PWA generation ───────────────────────────────────────────
@ -658,17 +650,9 @@ self.addEventListener('fetch', event => {{
(site_path / "service-worker.js").write_text(sw, encoding="utf-8") (site_path / "service-worker.js").write_text(sw, encoding="utf-8")
click.echo(f" Wrote service-worker.js (cache: {cache_name})") click.echo(f" Wrote service-worker.js (cache: {cache_name})")
# ─── HTTP helpers ───────────────────────────────────────────── # ─── GitHub template download ─────────────────────────────────
def _http_get(url: str) -> bytes: def _github_get(url: str) -> bytes:
req = urllib.request.Request(url, headers={"User-Agent": f"mdcms/{CLI_VERSION}"})
ctx = ssl.create_default_context(cafile=certifi.where())
with urllib.request.urlopen(req, timeout=15, context=ctx) as resp:
return resp.read()
def _http_get_github(url: str) -> bytes:
"""HTTP GET with GitHub API Accept header (for Contents API responses)."""
req = urllib.request.Request( req = urllib.request.Request(
url, url,
headers={ headers={
@ -681,134 +665,23 @@ def _http_get_github(url: str) -> bytes:
return resp.read() return resp.read()
# ─── Site manifest generation ───────────────────────────────── def _download_tree(api_url: str, dest: Path, depth: int = 0):
items = json.loads(_github_get(api_url).decode("utf-8"))
def generate_site_manifest(site_path: Path):
"""Write mdcms.json to site_path listing all deployable files and empty dirs."""
files = []
empty_dirs = []
for entry in sorted(site_path.rglob("*")):
rel = entry.relative_to(site_path)
# Skip anything inside a hidden directory or with a hidden name
if any(p.startswith(".") for p in rel.parts):
continue
if entry.is_file():
rel_str = str(rel).replace("\\", "/")
if rel_str != MANIFEST_FILENAME:
files.append(rel_str)
elif entry.is_dir():
# Only list dirs that have no non-hidden children
visible = [c for c in entry.iterdir() if not c.name.startswith(".")]
if not visible:
empty_dirs.append(str(rel).replace("\\", "/"))
manifest: dict = {
"mdcms": read_site_version(site_path) or "0.4",
"files": files,
}
if empty_dirs:
manifest["dirs"] = empty_dirs
(site_path / MANIFEST_FILENAME).write_text(
json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8"
)
click.echo(f" Wrote {MANIFEST_FILENAME} ({len(files)} files)")
# ─── Template download ────────────────────────────────────────
def _parse_github_url(url: str) -> "tuple | None":
"""Return (owner, repo, branch, subpath) for a GitHub URL, else None."""
m = GITHUB_URL_RE.match(url.strip())
if not m:
return None
owner = m.group(1)
repo = m.group(2)
branch = m.group(3) or "main"
subpath = (m.group(4) or "").strip("/")
return owner, repo, branch, subpath
def _fetch_manifest(base_url: str) -> "dict | None":
"""Fetch mdcms.json from base_url. Returns parsed dict or None if not found."""
url = base_url.rstrip("/") + "/" + MANIFEST_FILENAME
try:
data = _http_get(url)
manifest = json.loads(data.decode("utf-8"))
if isinstance(manifest.get("files"), list):
return manifest
except Exception:
pass
return None
def _apply_manifest(manifest: dict, base_url: str, dest: Path):
"""Download all files in manifest from base_url into dest."""
base = base_url.rstrip("/")
for rel in manifest.get("files", []):
file_dest = dest / rel
file_dest.parent.mkdir(parents=True, exist_ok=True)
click.echo(f" {rel}")
file_dest.write_bytes(_http_get(f"{base}/{rel}"))
for rel in manifest.get("dirs", []):
(dest / rel).mkdir(parents=True, exist_ok=True)
def _download_tree_api(api_url: str, dest: Path, depth: int = 0):
"""Recursively download from the GitHub Contents API (fallback when no manifest)."""
items = json.loads(_http_get_github(api_url).decode("utf-8"))
for item in items: for item in items:
item_dest = dest / item["name"] item_dest = dest / item["name"]
if item["type"] == "dir": if item["type"] == "dir":
item_dest.mkdir(parents=True, exist_ok=True) item_dest.mkdir(parents=True, exist_ok=True)
_download_tree_api(item["url"], item_dest, depth + 1) _download_tree(item["url"], item_dest, depth + 1)
elif item["type"] == "file": elif item["type"] == "file":
click.echo(f" {' ' * depth}{item['name']}") click.echo(f" {' ' * depth}{item['name']}")
item_dest.parent.mkdir(parents=True, exist_ok=True) item_dest.parent.mkdir(parents=True, exist_ok=True)
item_dest.write_bytes(_http_get(item["download_url"])) item_dest.write_bytes(_github_get(item["download_url"]))
def download_template(dest: Path, source: str = None): def download_template(dest: Path):
"""Download a site template from a URL or GitHub address.
source may be:
- A GitHub repo URL (https://github.com/owner/repo or .../tree/branch/path)
- Any HTTPS URL pointing to a deployed mdcms site that has mdcms.json
- None uses the built-in mdcms starter template
"""
effective = (source or TEMPLATE_BASE_URL).rstrip("/")
click.echo(f"Downloading site template into {dest} ...") click.echo(f"Downloading site template into {dest} ...")
try: try:
github = _parse_github_url(effective) _download_tree(GITHUB_CONTENTS_API, dest)
if github:
owner, repo, branch, subpath = github
raw_base = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}"
if subpath:
raw_base = f"{raw_base}/{subpath}"
manifest = _fetch_manifest(raw_base)
if manifest is not None:
_apply_manifest(manifest, raw_base, dest)
else:
# No manifest — fall back to GitHub Contents API tree walk
api_url = f"https://api.github.com/repos/{owner}/{repo}/contents"
if subpath:
api_url = f"{api_url}/{subpath}"
if branch not in ("main", "master"):
api_url += f"?ref={branch}"
_download_tree_api(api_url, dest)
else:
manifest = _fetch_manifest(effective)
if manifest is None:
if source:
raise click.ClickException(
f"No {MANIFEST_FILENAME} found at {effective}.\n"
"The URL must point to a deployed mdcms site with a manifest, "
"or to a GitHub repository."
)
raise click.ClickException(
f"Could not fetch template manifest from {effective}"
)
_apply_manifest(manifest, effective, dest)
click.echo(click.style("Template downloaded successfully.", fg="green")) click.echo(click.style("Template downloaded successfully.", fg="green"))
except urllib.error.URLError as e: except urllib.error.URLError as e:
raise click.ClickException(f"Download failed: {e}") raise click.ClickException(f"Download failed: {e}")
@ -848,22 +721,12 @@ def cli():
@cli.command() @cli.command()
@click.argument("name") @click.argument("name")
@click.argument("path", required=False, default=None) @click.argument("path", required=False, default=None, type=click.Path())
@click.option("--from", "source", default=None, metavar="URL", def register(name, path):
help="Download template from a GitHub repo or deployed site URL.")
def register(name, path, source):
"""Register a site by NAME at PATH (default: current directory). """Register a site by NAME at PATH (default: current directory).
PATH may be a local directory or a URL to download from. If no mdcms If no mdcms site is found at the target path, the starter template is
site is found at the local path, the template is downloaded from --from downloaded from GitHub automatically.
(or PATH if it is a URL, or the built-in mdcms starter by default).
\b
Examples:
mdcms register mysite
mdcms register mysite ./mydir
mdcms register mysite https://github.com/owner/repo
mdcms register mysite --from https://example.com/deployed-site
""" """
reg = load_registry() reg = load_registry()
@ -872,12 +735,6 @@ def register(name, path, source):
f"'{name}' is already registered. Use 'mdcms delete {name}' to remove it first." f"'{name}' is already registered. Use 'mdcms delete {name}' to remove it first."
) )
# If PATH looks like a URL, treat it as the download source rather than a local path.
if path and path.startswith(("http://", "https://", "git://")):
if source is None:
source = path
path = None
site_path = Path(path).resolve() if path else Path.cwd() site_path = Path(path).resolve() if path else Path.cwd()
if not site_path.is_dir(): if not site_path.is_dir():
@ -895,7 +752,7 @@ def register(name, path, source):
if site_version is None: if site_version is None:
click.echo(f"No mdcms site found at {site_path}.") click.echo(f"No mdcms site found at {site_path}.")
download_template(site_path, source) download_template(site_path)
site_version = read_site_version(site_path) site_version = read_site_version(site_path)
if site_version is None: if site_version is None:
raise click.ClickException( raise click.ClickException(

View file

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "mdcms" name = "mdcms"
version = "0.6.0" version = "0.4.0"
description = "MD-CMS — Markdown-based CMS companion CLI" description = "MD-CMS — Markdown-based CMS companion CLI"
readme = "README.md" readme = "README.md"
license = { text = "Apache-2.0" } license = { text = "Apache-2.0" }