mirror of
https://github.com/kbenestad/mdcms.git
synced 2026-06-18 07:24:31 +00:00
1356 lines
47 KiB
Python
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()
|