Compare commits

...

9 commits
v0.5.0 ... main

Author SHA1 Message Date
a25829984d
Update config file comment for version 0.6.0
Some checks failed
/ mirror (push) Has been cancelled
2026-06-10 22:06:03 +07:00
1f083fd12f
Update version comment from v0.4 to v0.6.0 2026-06-10 22:05:16 +07:00
Claude
c7fde737f2
chore: bump version to 0.6.0 (7 June 2026)
Some checks failed
/ mirror (push) Has been cancelled
https://claude.ai/code/session_01Ai8xRvmrzdhuTKiRQ2fnn9
2026-06-07 18:03:09 +00:00
Claude
c43d8415a4
chore: add Python entries to .gitignore
__pycache__/ and other Python build artifacts were untracked.

https://claude.ai/code/session_01Ai8xRvmrzdhuTKiRQ2fnn9
2026-06-07 18:01:13 +00:00
Claude
8e7f5d3ae9
feat: mdcms build writes mdcms.json; register accepts URLs
mdcms build now calls generate_site_manifest() at the end of every build,
writing mdcms.json to the site root. This file lists all deployable files
and empty directories, and is deployed alongside the site so any mdcms
user can register a copy of the site from its URL.

mdcms register now accepts a GitHub repo URL or plain HTTPS URL as PATH
or via --from. GitHub URLs try mdcms.json (raw content) first and fall
back to the Contents API tree-walk. Plain URLs require mdcms.json to be
present and fail with a clear error if it is not found.

- generate_site_manifest() added; called at end of run_build
- download_template(dest, source=None) dispatches on source type
- _parse_github_url() extracts owner/repo/branch/subpath from GitHub URLs
- _fetch_manifest() / _apply_manifest() handle the manifest protocol
- _download_tree_api() retained as GitHub Contents API fallback
- _http_get_github() carries Accept header for Contents API responses
- MANIFEST_FILENAME = "mdcms.json"; GITHUB_URL_RE added
- app/template-manifest.json replaced by app/mdcms.json
- register command: PATH accepts URL; --from option added

https://claude.ai/code/session_01Ai8xRvmrzdhuTKiRQ2fnn9
2026-06-07 18:00:24 +00:00
be698a2bdd
Update remote repository URL in mirror workflow 2026-06-08 00:55:21 +07:00
Claude
e559e67341
feat: manifest-driven template download
Replace the GitHub Contents API tree-walk with a flat manifest approach.
template-manifest.json lists every file and empty directory in the starter
template; download_template() fetches the manifest then pulls each file
directly as a raw URL, sidestepping git API rate limits and making the
template hostable from any HTTP source.

- GITHUB_CONTENTS_API / _github_get / _download_tree removed
- TEMPLATE_BASE_URL + TEMPLATE_MANIFEST constants added
- _http_get() replaces _github_get() (generic, no GitHub headers)
- download_template() accepts optional base_url for custom sources
- app/template-manifest.json added (v0.4, 35 files, 2 empty dirs)
- Generated files (manifest.json, service-worker.js, search.json)
  excluded from manifest — they belong to mdcms build output, not
  the starter template

https://claude.ai/code/session_01Ai8xRvmrzdhuTKiRQ2fnn9
2026-06-07 17:41:07 +00:00
Claude
810ed975e5
feat: clean URLs for section-id pages
Pages whose filename matches a nav section-id are now accessible at a
clean pathname URL (e.g. /timesheet) rather than /#pages/timesheet.md.
Adds app/404.html for GitHub Pages SPA routing support.

https://claude.ai/code/session_01Ai8xRvmrzdhuTKiRQ2fnn9
2026-06-07 17:33:08 +00:00
Claude
31330d19e2
feat: clean URLs for section-id pages
Pages whose filename matches a nav section-id now get a clean pathname
URL (e.g. /timesheet) instead of the hash-based /#pages/timesheet.md.

- _initialPathname captured at IIFE start; handles ?_route= from 404.html
- basePath determined by initBasePath() after nav data loads; subpath
  deployments (e.g. /mysite/) handled automatically
- navigateTo() uses replaceState to /slug for section-id pages and falls
  back to #hash for everything else
- popstate listener handles browser history if a clean URL was the entry
- resolveSlugToFile() validates that slug is both a section code and has
  a pages/{slug}.md entry in navData
- app/404.html added for GitHub Pages SPA routing

https://claude.ai/code/session_01Ai8xRvmrzdhuTKiRQ2fnn9
2026-06-07 17:23:30 +00:00
9 changed files with 389 additions and 27 deletions

View file

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

10
.gitignore vendored
View file

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

30
app/404.html Normal file
View file

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

View file

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

View file

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

45
app/mdcms.json Normal file
View file

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

View file

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

179
mdcms.py
View file

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

View file

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