mdcms/mdcms.py

1356 lines
47 KiB
Python

#!/usr/bin/env python3
#
# MD-CMS v0.2.2 — Companion CLI
#
# LICENCE
# Copyright 2026 Kristian Benestad | docs.benestad.net
#
# Licensed under the Apache License, Version 2.0 (the "Licence");
# you may not use this file except in compliance with the Licence.
# You may obtain a copy of the Licence at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the Licence is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the Licence for the specific language governing permissions and
# limitations under the Licence.
"""MD-CMS v0.2.2 — Companion CLI.
Scans `pages/` and `posts/` in the active website directory and writes
`nav.yml` and `search.json`. Also manages a registry of named website paths
so the tool can be invoked from anywhere on the system.
"""
import json
import os
import re
import sys
import subprocess
import time
import urllib.request
import webbrowser
import zipfile
from pathlib import Path
VERSION = "0.2"
DATE = "2026-04-16"
BANNER_URL = f"https://raw.githubusercontent.com/kbenestad/mdcms/refs/heads/main/resources/banner/v{VERSION}.txt"
HELP_URL = "https://docs.benestad.net"
CONFIG_DIR = Path.home() / ".mdcms"
PATHS_FILE = CONFIG_DIR / "paths.json"
SEP = "-" * 80
# ─── Path registry ───────────────────────────────────────────
def load_registry():
if PATHS_FILE.exists():
try:
return json.loads(PATHS_FILE.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
pass
return {"active": None, "paths": {}}
def save_registry(reg):
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
PATHS_FILE.write_text(json.dumps(reg, indent=2), encoding="utf-8")
def active_path():
reg = load_registry()
name = reg.get("active")
if not name:
return None, None
p = reg["paths"].get(name)
if not p:
return None, None
return name, Path(p)
# ─── Frontmatter parser ──────────────────────────────────────
def parse_frontmatter(filepath):
"""Return (meta_dict, body_text). Meta is a flat key:value map."""
try:
content = Path(filepath).read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError) as e:
print(f" Warning: could not read {filepath}: {e}")
return None, ""
match = re.match(r"^---\s*\n(.*?)\n---\s*\n", content, re.DOTALL)
if not match:
return {}, content
meta = {}
for line in match.group(1).split("\n"):
line = line.strip()
if not line or line.startswith("#"):
continue
colon = line.find(":")
if colon == -1:
continue
key = line[:colon].strip()
value = line[colon + 1:].strip()
if (value.startswith('"') and value.endswith('"')) or \
(value.startswith("'") and value.endswith("'")):
value = value[1:-1]
if value.lower() == "true":
value = True
elif value.lower() == "false":
value = False
elif re.match(r"^\d+$", value):
value = int(value)
meta[key] = value
return meta, content[match.end():]
# ─── Scanner ─────────────────────────────────────────────────
CATEGORY_CODE_RE = re.compile(r"^[a-zA-Z0-9\-]+$")
def parse_config_categories(text):
"""Extract category info from config.yml.
Returns {'use': bool, 'default_code': str|None, 'codes': [str, ...]}.
Only looks at `categories-use`, `default-category.code`, and `categories[].code`.
The `code` field must be the first key of each category item (per spec example).
"""
use = False
default_code = None
codes = []
in_default = False
in_categories = False
for raw in text.splitlines():
line = raw.rstrip()
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if not line.startswith(" "):
in_default = False
in_categories = False
if line.startswith("categories-use:"):
val = line.split(":", 1)[1].strip().strip('"\'')
use = val.lower() in ("yes", "true")
elif line.rstrip(":") == "default-category":
in_default = True
elif line.rstrip(":") == "categories":
in_categories = True
continue
if in_default and line.startswith(" ") and not line.startswith(" "):
m = re.match(r"^ (\S+):\s*(.*)$", line)
if m and m.group(1) == "code":
default_code = m.group(2).strip().strip('"\'')
if in_categories and line.startswith(" - "):
m = re.match(r"^ - (\S+):\s*(.*)$", line)
if m and m.group(1) == "code":
codes.append(m.group(2).strip().strip('"\''))
return {"use": use, "default_code": default_code, "codes": codes}
def identify_variant(path_rel, known_codes):
"""Split a .md path into (base_without_ext, category_code_or_None).
`base_without_ext` includes the directory and base name but no extension
and no category suffix. `known_codes` is the set of valid category codes
(default + additional). A trailing `.<code>` in the filename is only
recognised as a category suffix if `<code>` is in `known_codes`.
"""
if not path_rel.endswith(".md"):
return None, None
stem = path_rel[:-3]
base_name = os.path.basename(stem)
if "." in base_name:
head, _, suffix = stem.rpartition(".")
if suffix in known_codes:
return head, suffix
return stem, None
def scan_and_categorize(directory, known_codes):
"""Scan a directory for .md files. Each returned record includes `base` and
`code` (`None` for files without a category suffix)."""
records = []
if not os.path.isdir(directory):
return records
for root, dirs, files in os.walk(directory):
dirs.sort()
for name in sorted(files):
if not name.endswith(".md"):
continue
full = os.path.join(root, name)
rel = os.path.relpath(full, ".").replace("\\", "/")
base, code = identify_variant(rel, known_codes)
if base is None:
continue
meta, body = parse_frontmatter(full)
if meta is None or meta.get("draft", False):
continue
records.append({
"file": rel,
"base": base,
"code": code,
"title": meta.get("title") or os.path.basename(base).replace("_", " ").replace("-", " ").title(),
"sort": meta.get("sort"),
"section-id": meta.get("section-id"),
"author": meta.get("author"),
"date": str(meta.get("date", "")),
"datetime": str(meta.get("datetime", "")),
"created": str(meta.get("created", "")),
"modified": str(meta.get("modified", "")),
"language": meta.get("language", "en"),
"keywords": meta.get("keywords", ""),
"description": meta.get("description", ""),
"body": body[:5000],
})
return records
def group_by_base(records):
"""Group records by their conceptual base path. Returns {base: {code: record}}."""
groups = {}
for r in records:
groups.setdefault(r["base"], {})[r["code"]] = r
return groups
def select_primary(variants_dict, default_code):
"""Pick a representative record for a page group.
Priority: default-coded variant → base file → first available.
"""
if default_code and default_code in variants_dict:
return variants_dict[default_code]
if None in variants_dict:
return variants_dict[None]
return next(iter(variants_dict.values()))
def _coerce(v):
"""Coerce a YAML scalar string to a Python value."""
if v == "" or v.lower() in ("null", "~"):
return None
if v == "true":
return True
if v == "false":
return False
if (v.startswith('"') and v.endswith('"')) or (v.startswith("'") and v.endswith("'")):
return v[1:-1]
if re.match(r"^-?\d+$", v):
return int(v)
m = re.match(r"^\[(.*)\]$", v)
if m:
inner = m.group(1).strip()
return [_coerce(x.strip()) for x in inner.split(",")] if inner else []
return v
def _emit_value(v):
"""Quote a YAML scalar if it contains characters that would confuse the parser."""
if v is None:
return ""
s = str(v)
if s == "" or any(c in s for c in ':"\'#') or s.lower() in ("true", "false", "null"):
return '"' + s.replace('"', '\\"') + '"'
return s
def parse_nav_yml(text):
"""Parse the nav.yml subset we emit. Returns (sections, pages) as lists of dicts.
Handles:
- top-level keys `sections:` and `pages:`
- list items at 2-space indent: ` - key: value`
- item properties at 4-space indent: ` key: value`
- one level of nested dict at 6-space indent (for `categorynames`)
Ignores unknown top-level keys. Not a general YAML parser.
"""
sections, pages = [], []
block = None
item = None
nested_key = None
def flush():
nonlocal item
if item is None:
return
if block == "sections":
sections.append(item)
elif block == "pages":
pages.append(item)
item = None
for raw in text.splitlines():
line = raw.rstrip()
stripped = line.lstrip()
if not stripped or stripped.startswith("#"):
continue
# Top-level key
if not line.startswith(" "):
flush()
key = line.split(":", 1)[0].strip()
block = key if key in ("sections", "pages") else None
nested_key = None
continue
if block is None:
continue
# 6-space nested dict content
m = re.match(r"^ (\S[^:]*):\s*(.*)$", line)
if m and nested_key is not None and item is not None:
item.setdefault(nested_key, {})[m.group(1).strip()] = _coerce(m.group(2).strip())
continue
# 2-space list item start
m = re.match(r"^ - (\S[^:]*):\s*(.*)$", line)
if m:
flush()
item = {m.group(1).strip(): _coerce(m.group(2).strip())}
nested_key = None
continue
if item is None:
continue
# 4-space item property
m = re.match(r"^ (\S[^:]*):\s*(.*)$", line)
if m:
key = m.group(1).strip()
val = m.group(2).strip()
if val == "":
nested_key = key
item[key] = {}
else:
nested_key = None
item[key] = _coerce(val)
flush()
return sections, pages
# ─── Section merge (phase 2) ─────────────────────────────────
def merge_sections(page_entries, existing_sections):
"""Merge existing sections with auto-created stubs for new section-ids.
Returns (merged_sections, auto_created_codes).
Preserves every field on existing sections; only adds new entries.
"""
by_code = {s["code"]: dict(s) for s in existing_sections if s.get("code")}
referenced = sorted({p.get("section-id") for p in page_entries if p.get("section-id")})
auto_created = []
for code in referenced:
if code in by_code:
continue
used_sorts = {s.get("sort") for s in by_code.values() if isinstance(s.get("sort"), int)}
next_sort = 100
while next_sort in used_sorts:
next_sort += 10
by_code[code] = {
"code": code,
"defaultname": code.replace("-", " ").replace("_", " ").title(),
"sort": next_sort,
"pagesvisibility": "visible",
}
auto_created.append(code)
merged = sorted(by_code.values(), key=lambda s: (s.get("sort") or 999, s["code"]))
return merged, auto_created
def build_page_nav(page_groups, existing_pages, categories_use=False, default_code=None):
"""Produce the page nav list. One entry per conceptual page (grouped by base).
When `categories_use`, adds `variants` (list of category codes with an
actual file on disk — the base file counts as the default category) and
`titles` (per-category titles). Preserves nav.yml `sort` when the file
path matches.
"""
existing_by_file = {p["file"]: p for p in existing_pages if p.get("file")}
out = []
for base, variants in sorted(page_groups.items()):
file = base + ".md"
primary = select_primary(variants, default_code)
existing = existing_by_file.get(file, {})
sort = existing.get("sort")
if sort is None:
sort = primary.get("sort")
if sort is None:
sort = 100
entry = {
"file": file,
"title": primary.get("title", ""),
"section-id": primary.get("section-id"),
"sort": sort,
}
if categories_use:
covered = set()
titles = {}
for code, record in variants.items():
key = code if code is not None else default_code
if key is None:
continue
covered.add(key)
titles[key] = record.get("title", "")
entry["variants"] = sorted(covered)
entry["titles"] = titles
out.append(entry)
out.sort(key=lambda p: (p["sort"], p["file"]))
return out
# ─── Generators ──────────────────────────────────────────────
def generate_nav_yml(sections, pages, categories_use=False):
"""Emit nav.yml with `sections:` and `pages:` blocks."""
lines = [
"# nav.yml — generated by mdcms.py",
"# Manual edits to section metadata (defaultname, sort, parent, parent-sort,",
"# pagesvisibility, categorynames) are preserved on rebuild. New sections",
"# are auto-created from page frontmatter section-id values.",
"",
"sections:",
]
if not sections:
lines.append(" # (none yet — add section-id to page frontmatter to auto-create)")
else:
for s in sections:
lines.append(f" - code: {s['code']}")
lines.append(f" defaultname: {_emit_value(s.get('defaultname', s['code']))}")
lines.append(f" sort: {s.get('sort', 100)}")
if s.get("parent"):
lines.append(f" parent: {s['parent']}")
lines.append(f" parent-sort: {s.get('parent-sort', 100)}")
lines.append(f" pagesvisibility: {s.get('pagesvisibility', 'visible')}")
cn = s.get("categorynames") or {}
if cn:
lines.append(" categorynames:")
for k, v in cn.items():
lines.append(f" {k}: {_emit_value(v)}")
lines.append("")
lines.append("pages:")
if not pages:
lines.append(" # (no pages)")
else:
for p in pages:
lines.append(f" - file: {p['file']}")
lines.append(f" title: {_emit_value(p['title'])}")
if p.get("section-id"):
lines.append(f" section-id: {p['section-id']}")
lines.append(f" sort: {p.get('sort', 100)}")
if categories_use and p.get("variants"):
lines.append(f" variants: [{', '.join(p['variants'])}]")
if categories_use and p.get("titles"):
lines.append(" titles:")
for code, title in p["titles"].items():
lines.append(f" {code}: {_emit_value(title)}")
lines.append("")
return "\n".join(lines)
def generate_search_json(records, categories_use=False, default_code=None):
"""Emit search.json. When `categories_use`, each entry gets a `category` field
and the `file` is the conceptual base path (so search results link to the
same URL regardless of which variant matched)."""
out = []
for r in records:
file_path = (r["base"] + ".md") if "base" in r else r.get("file", "")
entry = {
"file": file_path,
"title": r.get("title", ""),
"section-id": r.get("section-id"),
"keywords": r.get("keywords", ""),
"description": r.get("description", ""),
"author": r.get("author"),
"date": r.get("date", ""),
"datetime": r.get("datetime", ""),
"language": r.get("language", "en"),
"body": r.get("body", ""),
}
if categories_use:
code = r.get("code")
entry["category"] = code if code is not None else default_code
out.append(entry)
return json.dumps(out, indent=2, ensure_ascii=False)
# ─── Banner ──────────────────────────────────────────────────
def load_banner():
try:
req = urllib.request.Request(BANNER_URL, headers={"User-Agent": f"mdcms/{VERSION}"})
with urllib.request.urlopen(req, timeout=3) as r:
return r.read().decode("utf-8").strip()
except Exception:
return None
# ─── UI helpers ──────────────────────────────────────────────
def clear():
os.system("cls" if os.name == "nt" else "clear")
def header():
print(f"MD-CMS v{VERSION} ({DATE}) (C) Kristian Benestad")
print("Apache 2.0 Licence")
print(SEP)
def pause():
input("\nPress Enter to continue...")
# ─── Build actions (options 3 & 4) ───────────────────────────
def do_build_nav_and_search(project):
"""Option 3: scan pages/ and posts/ in project/website/, write nav.yml and search.json."""
site = project / "website"
if not site.is_dir():
print(f" Error: {site} not found.")
return
os.chdir(site)
print(f"Working directory: {site}")
print()
if not os.path.isdir("pages"):
print(" Error: pages/ folder not found.")
return
# Load category config from config.yml so filename parsing can recognise
# category suffixes. Missing / disabled = no category handling.
cat_cfg = {"use": False, "default_code": None, "codes": []}
if os.path.isfile("config.yml"):
try:
cat_cfg = parse_config_categories(Path("config.yml").read_text(encoding="utf-8"))
except Exception as e:
print(f" Warning: could not read category config ({e}).")
# Validate codes
all_declared = [c for c in [cat_cfg["default_code"]] + cat_cfg["codes"] if c]
invalid = [c for c in all_declared if not CATEGORY_CODE_RE.match(c)]
if invalid:
print(f" Error: invalid category code(s) {invalid}. Must match [a-zA-Z0-9-]+.")
return
if cat_cfg["use"] and not cat_cfg["default_code"]:
print(" Error: categories-use: yes but no default-category.code defined.")
return
known_codes = set(all_declared) if cat_cfg["use"] else set()
# Scan
page_records = scan_and_categorize("pages", known_codes)
post_records = scan_and_categorize("posts", known_codes)
print(f"Scanned pages/ — {len(page_records)} file(s)")
print(f"Scanned posts/ — {len(post_records)} file(s)")
page_groups = group_by_base(page_records)
# Existing nav.yml → preserve manual edits
existing_sections, existing_pages = [], []
if os.path.isfile("nav.yml"):
try:
existing_sections, existing_pages = parse_nav_yml(
Path("nav.yml").read_text(encoding="utf-8")
)
except Exception as e:
print(f" Warning: could not parse existing nav.yml ({e}); starting fresh.")
# One representative record per conceptual page, for section-id discovery
primary_entries = [select_primary(v, cat_cfg["default_code"]) for v in page_groups.values()]
sections, auto_created = merge_sections(primary_entries, existing_sections)
page_nav = build_page_nav(
page_groups, existing_pages,
categories_use=cat_cfg["use"],
default_code=cat_cfg["default_code"],
)
Path("nav.yml").write_text(
generate_nav_yml(sections, page_nav, categories_use=cat_cfg["use"]),
encoding="utf-8",
)
print("Wrote nav.yml")
# Filter out draft-section pages from search.
draft_codes = {s["code"] for s in sections if s.get("pagesvisibility") == "draft"}
live_pages = [r for r in page_records if r.get("section-id") not in draft_codes]
Path("search.json").write_text(
generate_search_json(
live_pages + post_records,
categories_use=cat_cfg["use"],
default_code=cat_cfg["default_code"],
),
encoding="utf-8",
)
print(f"Wrote search.json ({len(live_pages) + len(post_records)} entries)")
if auto_created:
print()
print(f" Notice: {len(auto_created)} section(s) auto-created from page frontmatter:")
for c in auto_created:
print(f" - {c}")
print(" Edit nav.yml to customise defaultname, sort, parent, or pagesvisibility.")
def do_build_search_only(project):
"""Option 4: regenerate search.json only."""
site = project / "website"
if not site.is_dir():
print(f" Error: {site} not found.")
return
os.chdir(site)
cat_cfg = {"use": False, "default_code": None, "codes": []}
if os.path.isfile("config.yml"):
try:
cat_cfg = parse_config_categories(Path("config.yml").read_text(encoding="utf-8"))
except Exception:
pass
known_codes = set(c for c in [cat_cfg["default_code"]] + cat_cfg["codes"] if c) if cat_cfg["use"] else set()
page_records = scan_and_categorize("pages", known_codes)
post_records = scan_and_categorize("posts", known_codes)
draft_codes = set()
if os.path.isfile("nav.yml"):
try:
sections, _ = parse_nav_yml(Path("nav.yml").read_text(encoding="utf-8"))
draft_codes = {s["code"] for s in sections if s.get("pagesvisibility") == "draft"}
except Exception:
pass
live_pages = [r for r in page_records if r.get("section-id") not in draft_codes]
Path("search.json").write_text(
generate_search_json(
live_pages + post_records,
categories_use=cat_cfg["use"],
default_code=cat_cfg["default_code"],
),
encoding="utf-8",
)
print(f"Wrote search.json — {len(live_pages) + len(post_records)} entries.")
# ─── Option 7: path management ───────────────────────────────
def manage_paths():
while True:
clear()
header()
reg = load_registry()
print("Website paths\n")
if not reg["paths"]:
print(" (none defined)")
else:
for name, p in reg["paths"].items():
marker = "*" if name == reg.get("active") else " "
print(f" {marker} {name}{p}")
print()
print(" a Add a path")
print(" s Set active path")
print(" r Remove a path")
print(" b Back")
print()
choice = input("Select: ").strip().lower()
if choice == "a":
name = input("Name: ").strip()
if not name:
continue
p = input("Path to project root (containing website/ folder): ").strip()
if not p:
continue
path = Path(p).expanduser().resolve()
if not path.is_dir():
print(f" Not a directory: {path}")
pause()
continue
if not (path / "website").is_dir():
print(f" No website/ subfolder in: {path}")
pause()
continue
reg["paths"][name] = str(path)
if not reg.get("active"):
reg["active"] = name
save_registry(reg)
pause()
elif choice == "s":
if not reg["paths"]:
pause()
continue
name = input("Name to activate: ").strip()
if name in reg["paths"]:
reg["active"] = name
save_registry(reg)
else:
print(" Not found.")
pause()
elif choice == "r":
if not reg["paths"]:
pause()
continue
name = input("Name to remove: ").strip()
if name in reg["paths"]:
del reg["paths"][name]
if reg.get("active") == name:
reg["active"] = next(iter(reg["paths"]), None)
save_registry(reg)
else:
print(" Not found.")
pause()
elif choice == "b":
return
# ─── Option 8: webserver ─────────────────────────────────────
def start_webserver(project):
port = 8800
site = project / "website"
if not site.is_dir():
print(f"\n Error: {site} not found.")
return
os.chdir(site)
print(f"\nStarting python3 -m http.server {port} in {site}")
print("Press Ctrl+C to stop.\n")
try:
proc = subprocess.Popen([sys.executable, "-m", "http.server", str(port)])
time.sleep(0.8)
webbrowser.open(f"http://localhost:{port}/")
proc.wait()
except KeyboardInterrupt:
proc.terminate()
print("\nStopped.")
# ─── Option 9 & 10 ───────────────────────────────────────────
def online_help():
print(f"Opening {HELP_URL} ...")
webbrowser.open(HELP_URL)
pause()
def about():
clear()
header()
print("MD-CMS is a lightweight Markdown-based CMS by Kristian Benestad.")
print()
print(f" Docs: https://docs.benestad.net")
print(f" Repo: https://github.com/kbenestad/mdcms")
print()
print("Licensed under the Apache License, Version 2.0.")
print(" https://www.apache.org/licenses/LICENSE-2.0")
pause()
# ─── Validation ──────────────────────────────────────────────
def _read_file(path):
"""Read a file, returning (text, error). On failure text is None."""
try:
return Path(path).read_text(encoding="utf-8"), None
except OSError as e:
return None, str(e)
def validate_config(site):
"""Check config.yml for missing or invalid values.
Returns a list of dicts: {'field': ..., 'issue': ..., 'current': ...}.
"""
path = site / "config.yml"
text, err = _read_file(path)
if text is None:
return [{"field": "config.yml", "issue": f"Cannot read file: {err}", "current": None}]
issues = []
# Simple key extraction from the raw text (reuses our non-YAML parser style)
def get_top(key):
for line in text.splitlines():
if line.startswith(key + ":"):
return line.split(":", 1)[1].strip().strip('"\'')
return None
# sitename
sn = get_top("sitename")
if not sn:
issues.append({"field": "sitename", "issue": "Missing site name", "current": sn})
# navigation
nav = get_top("navigation")
if nav and nav not in ("sidebar", "topbar"):
issues.append({"field": "navigation", "issue": f"Must be 'sidebar' or 'topbar'", "current": nav})
# category validation
cat_cfg = parse_config_categories(text)
if cat_cfg["use"]:
if not cat_cfg["default_code"]:
issues.append({"field": "default-category.code",
"issue": "categories-use: yes but no default category code defined",
"current": None})
if not cat_cfg["codes"]:
issues.append({"field": "categories",
"issue": "categories-use: yes but no additional categories defined",
"current": None})
# Validate all codes
all_codes = [c for c in [cat_cfg["default_code"]] + cat_cfg["codes"] if c]
for code in all_codes:
if not CATEGORY_CODE_RE.match(code):
issues.append({"field": f"category code '{code}'",
"issue": "Must match [a-zA-Z0-9-]+",
"current": code})
# Check for duplicate codes
seen = set()
for code in all_codes:
if code in seen:
issues.append({"field": f"category code '{code}'",
"issue": "Duplicate category code",
"current": code})
seen.add(code)
return issues
def validate_nav(site):
"""Check nav.yml for missing or invalid values.
Returns a list of dicts: {'field': ..., 'issue': ..., 'current': ...}.
"""
path = site / "nav.yml"
text, err = _read_file(path)
if text is None:
return [{"field": "nav.yml", "issue": f"Cannot read file: {err}", "current": None}]
try:
sections, pages = parse_nav_yml(text)
except Exception as e:
return [{"field": "nav.yml", "issue": f"Parse error: {e}", "current": None}]
issues = []
# Load category config to check categorynames
config_text, _ = _read_file(site / "config.yml")
cat_cfg = parse_config_categories(config_text) if config_text else {"use": False, "default_code": None, "codes": []}
sectionnames_mode = None
if config_text:
for line in config_text.splitlines():
if line.startswith("categories-sectionnames:"):
sectionnames_mode = line.split(":", 1)[1].strip().strip('"\'')
all_codes = [c for c in [cat_cfg["default_code"]] + cat_cfg["codes"] if c]
# Section checks
section_codes = set()
for i, s in enumerate(sections):
label = f"sections[{i}]"
if not s.get("code"):
issues.append({"field": f"{label}.code", "issue": "Missing section code", "current": None})
else:
section_codes.add(s["code"])
if not s.get("defaultname"):
issues.append({"field": f"{label}.defaultname",
"issue": f"Missing default name for section '{s.get('code', '?')}'",
"current": None})
pv = s.get("pagesvisibility", "visible")
if pv not in ("visible", "hidden", "draft"):
issues.append({"field": f"{label}.pagesvisibility",
"issue": f"Must be visible, hidden, or draft",
"current": pv})
if s.get("parent") and s.get("parent-sort") is None:
issues.append({"field": f"{label}.parent-sort",
"issue": f"parent is set but parent-sort is missing",
"current": None})
# categorynames check
if cat_cfg["use"] and sectionnames_mode == "per-category":
cn = s.get("categorynames") or {}
for code in all_codes:
if code not in cn:
issues.append({"field": f"{label}.categorynames.{code}",
"issue": f"Missing category name for '{code}' in section '{s.get('code', '?')}'",
"current": None})
# Page checks
for i, p in enumerate(pages):
label = f"pages[{i}]"
if not p.get("file"):
issues.append({"field": f"{label}.file", "issue": "Missing file path", "current": None})
if not p.get("title"):
issues.append({"field": f"{label}.title",
"issue": f"Missing title for '{p.get('file', '?')}'",
"current": None})
sid = p.get("section-id")
if sid and sid not in section_codes:
issues.append({"field": f"{label}.section-id",
"issue": f"Section-id '{sid}' not defined in sections",
"current": sid})
return issues
def _print_issues(issues, label):
"""Print validation issues in a readable format."""
if not issues:
print(f"\n {label}: no issues found.")
return
print(f"\n {label}: {len(issues)} issue(s):\n")
for iss in issues:
cur = f" (current: {iss['current']})" if iss["current"] is not None else ""
print(f"{iss['field']}: {iss['issue']}{cur}")
# ─── Option 1: Prepare for upload ───────────────────────────
def do_prepare_for_upload(project):
"""Validate config + nav, build search.json, create zip."""
site = project / "website"
if not site.is_dir():
print(f"\n Error: {site} not found.")
return
print()
# Step 1: Validate config.yml
config_issues = validate_config(site)
_print_issues(config_issues, "config.yml")
# Step 2: Validate nav.yml
nav_issues = validate_nav(site)
_print_issues(nav_issues, "nav.yml")
total_issues = len(config_issues) + len(nav_issues)
if total_issues > 0:
print()
fix = input(f" {total_issues} issue(s) found. Continue anyway? (y/n): ").strip().lower()
if fix != "y":
print(" Aborted.")
return
# Step 3: Build search.json
print()
try:
do_build_search_only(project)
except Exception as e:
print(f" Error building search.json: {e}")
return
# Step 4: Create zip
print()
cwd = Path.cwd()
default_dest = cwd / "website.zip"
dest_input = input(f" Zip destination [{default_dest}]: ").strip()
dest = Path(dest_input) if dest_input else default_dest
dest = dest.expanduser().resolve()
# Ensure parent directory exists
dest.parent.mkdir(parents=True, exist_ok=True)
try:
count = 0
with zipfile.ZipFile(dest, "w", zipfile.ZIP_DEFLATED) as zf:
for root, dirs, files in os.walk(site):
dirs.sort()
for name in sorted(files):
full = Path(root) / name
arcname = full.relative_to(site)
zf.write(full, arcname)
count += 1
print(f"\n Created {dest} ({count} files)")
except OSError as e:
print(f"\n Error creating zip: {e}")
# ─── Option 2: Build from scratch ───────────────────────────
def do_build_from_scratch(project):
"""Interactive wizard to create config.yml, folder structure, and home.md."""
site = project / "website"
print("\n Build from scratch wizard\n")
# Check for existing config
if (site / "config.yml").exists():
overwrite = input(" config.yml already exists. Overwrite? (y/n): ").strip().lower()
if overwrite != "y":
print(" Aborted.")
return
# Gather settings
sitename = input(" Site name: ").strip() or "My Site"
nav_choice = ""
while nav_choice not in ("sidebar", "topbar"):
nav_choice = input(" Navigation layout (sidebar/topbar) [sidebar]: ").strip().lower() or "sidebar"
nav_pos = "left"
if nav_choice == "sidebar":
while nav_pos not in ("left", "right"):
nav_pos = input(" Navigation position (left/right) [left]: ").strip().lower() or "left"
search_choice = input(" Enable search? (yes/no) [yes]: ").strip().lower() or "yes"
search_on = search_choice in ("yes", "y", "true")
# Categories
use_categories = input(" Use categories? (yes/no) [no]: ").strip().lower()
use_categories = use_categories in ("yes", "y")
cat_block = ""
if use_categories:
default_code = input(" Default category code (e.g. en): ").strip() or "en"
default_name = input(f" Default category display name [{default_code}]: ").strip() or default_code
select_text = input(" Category selector label [Select language]: ").strip() or "Select language"
extra_cats = []
print(" Add additional categories (leave code blank to finish):")
while True:
code = input(" Code: ").strip()
if not code:
break
if not CATEGORY_CODE_RE.match(code):
print(f" Invalid code '{code}'. Must match [a-zA-Z0-9-]+.")
continue
name = input(f" Display name [{code}]: ").strip() or code
direction = input(f" Direction (ltr/rtl) [ltr]: ").strip().lower() or "ltr"
extra_cats.append({"code": code, "name": name, "direction": direction})
if not extra_cats:
print(" No additional categories added — disabling categories.")
use_categories = False
else:
cat_block = f"""
# ─── Category settings ───────────────────────────
categories-use: yes
categories-sectionnames: same
categories-selecttext: "{select_text}"
# ─── Default category ────────────────────────────
default-category:
code: {default_code}
name: {default_name}
direction: ltr
message: "Read in {default_name}"
# ─── Additional categories ───────────────────────
categories:
"""
for c in extra_cats:
cat_block += f""" - code: {c['code']}
name: {c['name']}
direction: {c['direction']}
message: "{c['name']}"
notfoundmessage: "Not available in {c['name']}"
"""
# Create folder structure
for d in ["pages", "posts", "assets/fonts", "assets/images"]:
(site / d).mkdir(parents=True, exist_ok=True)
# Write config.yml
config_text = f"""# ─── Site settings ───────────────────────────────
sitename: {sitename}
navigation: {nav_choice}
{"nav-position: " + nav_pos if nav_choice == "sidebar" else ""}
search: {"true" if search_on else "false"}
{cat_block}
# ─── Appearance ──────────────────────────────────
light:
bg: "#ededed"
bg-nav: "#ffffff"
font-colour: "#1a1a1a"
accent: "#29307d"
accent-hover: "#f9ad18"
dark:
bg: "#14171c"
bg-nav: "#1a1e25"
font-colour: "#d4d4d4"
accent: "#7b83d4"
accent-hover: "#f9ad18"
"""
(site / "config.yml").write_text(config_text, encoding="utf-8")
print(f"\n Wrote config.yml")
# Write home.md if it doesn't exist
home = site / "pages" / "home.md"
if not home.exists():
home.write_text(
f"---\ntitle: Home\nsort: 100\n---\n\n# Welcome to {sitename}\n\n"
"This is the default landing page.\n",
encoding="utf-8",
)
print(" Created pages/home.md")
# Create .gitkeep files in empty dirs
for d in ["posts", "assets/fonts", "assets/images"]:
gk = site / d / ".gitkeep"
if not gk.exists() and not any((site / d).iterdir()):
gk.write_text("", encoding="utf-8")
# Run option 3 to generate nav.yml + search.json
print()
try:
do_build_nav_and_search(project)
except Exception as e:
print(f" Error during build: {e}")
# ─── Option 5: Correct config.yml ───────────────────────────
def do_correct_config(project):
"""Check config.yml for missing/invalid values and prompt to fix."""
site = project / "website"
config_path = site / "config.yml"
issues = validate_config(site)
_print_issues(issues, "config.yml")
if not issues:
return
fix = input("\n Fix interactively? (y/n): ").strip().lower()
if fix != "y":
return
text, err = _read_file(config_path)
if text is None:
print(f" Cannot read config.yml: {err}")
return
lines = text.splitlines()
for iss in issues:
field = iss["field"]
print(f"\n Issue: {field}{iss['issue']}")
if field == "sitename":
val = input(" Enter site name: ").strip()
if val:
# Find and replace or append
replaced = False
for i, ln in enumerate(lines):
if ln.startswith("sitename:"):
lines[i] = f"sitename: {val}"
replaced = True
break
if not replaced:
lines.insert(0, f"sitename: {val}")
print(f" Set sitename to: {val}")
elif field == "navigation":
val = ""
while val not in ("sidebar", "topbar"):
val = input(" Navigation (sidebar/topbar): ").strip().lower()
for i, ln in enumerate(lines):
if ln.startswith("navigation:"):
lines[i] = f"navigation: {val}"
break
elif "category code" in field and "Invalid" in iss.get("issue", ""):
print(" Please edit config.yml manually to fix category codes.")
else:
print(" Please edit config.yml manually to fix this issue.")
# Write back
config_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
print("\n Saved config.yml")
# ─── Option 6: Correct nav.yml ─────────────────────────────
def do_correct_nav(project):
"""Check nav.yml for missing/invalid values and prompt to fix."""
site = project / "website"
nav_path = site / "nav.yml"
issues = validate_nav(site)
_print_issues(issues, "nav.yml")
if not issues:
return
# For missing categorynames, offer to auto-fill from defaultname
text, err = _read_file(nav_path)
if text is None:
print(f" Cannot read nav.yml: {err}")
return
try:
sections, pages = parse_nav_yml(text)
except Exception as e:
print(f" Cannot parse nav.yml: {e}")
return
# Separate auto-fixable (missing categorynames) from manual fixes
catname_issues = [i for i in issues if ".categorynames." in i["field"]]
other_issues = [i for i in issues if ".categorynames." not in i["field"]]
if other_issues:
print("\n The following issues require manual editing of nav.yml:")
for iss in other_issues:
print(f"{iss['field']}: {iss['issue']}")
if catname_issues:
print(f"\n {len(catname_issues)} missing category name(s) found.")
fill = input(" Auto-fill missing categorynames from defaultname? (y/n): ").strip().lower()
if fill == "y":
# Load category codes
config_text, _ = _read_file(site / "config.yml")
cat_cfg = parse_config_categories(config_text) if config_text else {"use": False, "default_code": None, "codes": []}
all_codes = [c for c in [cat_cfg["default_code"]] + cat_cfg["codes"] if c]
for s in sections:
cn = s.get("categorynames") or {}
changed = False
for code in all_codes:
if code not in cn:
cn[code] = s.get("defaultname", s.get("code", ""))
changed = True
if changed:
s["categorynames"] = cn
# Regenerate nav.yml
config_text2, _ = _read_file(site / "config.yml")
cat_use = False
if config_text2:
cat_use = parse_config_categories(config_text2)["use"]
nav_path.write_text(
generate_nav_yml(sections, pages, categories_use=cat_use),
encoding="utf-8",
)
print(" Saved nav.yml with auto-filled category names.")
print(" Edit nav.yml to provide correct translations.")
# ─── Sections-sitemap (§2.3) ────────────────────────────────
# Note: sections-sitemap is not yet implemented. Section headings
# remain non-clickable in the renderer.
# ─── Main menu ───────────────────────────────────────────────
def main_menu():
while True:
clear()
header()
banner = load_banner()
if banner:
print(banner)
print()
name, website = active_path()
if name:
print(f"WEBSITE: {name} ({website})")
else:
print("WEBSITE: (none — select in option 7)")
print()
print(" 1 Prepare for upload")
print(" 2 Build config.yml and nav.yml from scratch")
print(" 3 Build nav.yml and search.json (new pages/sections)")
print(" 4 Build search.json (updated pages only)")
print(" 5 Correct missing values in config.yml")
print(" 6 Correct missing values in nav.yml")
print(" 7 Define website path")
print(" 8 Start python webserver")
print(" 9 Online help")
print(" 10 About MD-CMS")
print()
print(SEP)
choice = input("Select option (q to quit): ").strip().lower()
if choice == "q":
return
if choice == "7":
manage_paths()
continue
if choice == "10":
about()
continue
if choice == "9":
online_help()
continue
# Options 1-6 and 8 require an active website path
if choice in {"1", "2", "3", "4", "5", "6", "8"}:
if not website:
print("\n No active website path. Use option 7 first.")
pause()
continue
if not (website / "website").is_dir() and choice != "2":
print(f"\n Error: {website / 'website'} not found.")
pause()
continue
if choice == "1":
try:
do_prepare_for_upload(website)
except Exception as e:
print(f"\n Error: {e}")
pause()
elif choice == "2":
try:
do_build_from_scratch(website)
except Exception as e:
print(f"\n Error: {e}")
pause()
elif choice == "3":
try:
do_build_nav_and_search(website)
except Exception as e:
print(f"\n Error: {e}")
pause()
elif choice == "4":
try:
do_build_search_only(website)
except Exception as e:
print(f"\n Error: {e}")
pause()
elif choice == "5":
try:
do_correct_config(website)
except Exception as e:
print(f"\n Error: {e}")
pause()
elif choice == "6":
try:
do_correct_nav(website)
except Exception as e:
print(f"\n Error: {e}")
pause()
elif choice == "8":
start_webserver(website)
def main():
try:
main_menu()
except KeyboardInterrupt:
print()
if __name__ == "__main__":
main()