#!/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 `.` in the filename is only recognised as a category suffix if `` 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()