mirror of
https://github.com/kbenestad/mdcms.git
synced 2026-06-18 15:24:32 +00:00
Compare commits
7 commits
8295cbca2c
...
c7fde737f2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7fde737f2 | ||
|
|
c43d8415a4 | ||
|
|
8e7f5d3ae9 | ||
| be698a2bdd | |||
|
|
e559e67341 | ||
|
|
810ed975e5 | ||
|
|
31330d19e2 |
8 changed files with 385 additions and 23 deletions
2
.github/workflows/mirror.yml
vendored
2
.github/workflows/mirror.yml
vendored
|
|
@ -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
10
.gitignore
vendored
|
|
@ -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
30
app/404.html
Normal 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>
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
u.hash = '#' + file;
|
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;
|
||||||
|
}
|
||||||
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
45
app/mdcms.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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
179
mdcms.py
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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" }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue