This commit is contained in:
@@ -24,13 +24,58 @@ from typing import Any
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
def looks_like_content_root(path: Path) -> bool:
|
||||
return (path / "authoring_server.py").exists() and (
|
||||
(path / "blogs").exists()
|
||||
or (path / "posts").exists()
|
||||
or (path / "lima").exists()
|
||||
)
|
||||
|
||||
|
||||
def resolve_root() -> Path:
|
||||
env_root = os.environ.get("AUTHOR_ROOT")
|
||||
if env_root:
|
||||
return Path(env_root).expanduser().resolve()
|
||||
candidates = [
|
||||
Path.cwd(),
|
||||
Path(__file__).resolve().parent,
|
||||
]
|
||||
workspace = os.environ.get("GITHUB_WORKSPACE")
|
||||
if workspace:
|
||||
candidates.insert(0, Path(workspace))
|
||||
for base in list(candidates):
|
||||
candidates.extend(base.parents)
|
||||
seen = set()
|
||||
for candidate in candidates:
|
||||
resolved = candidate.expanduser().resolve()
|
||||
if resolved in seen:
|
||||
continue
|
||||
seen.add(resolved)
|
||||
if looks_like_content_root(resolved):
|
||||
return resolved
|
||||
return Path(__file__).resolve().parent
|
||||
|
||||
|
||||
ROOT = resolve_root()
|
||||
BLOGS_DIR = ROOT / "blogs"
|
||||
POSTS_DIR = ROOT / "posts"
|
||||
LIMA_DIR = ROOT / "lima"
|
||||
HZONE_ASSETS_DIR = ROOT / "assets" / "images" / "hzone"
|
||||
EXCLUDED_CONTENT_DIR_NAMES = {
|
||||
".agents",
|
||||
".codex",
|
||||
".git",
|
||||
".packages",
|
||||
".venv",
|
||||
"__pycache__",
|
||||
"assets",
|
||||
"backups",
|
||||
"output",
|
||||
"tags",
|
||||
}
|
||||
GENERATED_ORG_NAMES = {
|
||||
"blogs-list.org",
|
||||
"books-list.org",
|
||||
"posts-list.org",
|
||||
"career-list.org",
|
||||
"sitemap.org",
|
||||
@@ -249,12 +294,16 @@ def safe_relative_path(path: str) -> Path:
|
||||
full = (ROOT / rel).resolve()
|
||||
if not full.is_relative_to(ROOT):
|
||||
raise ValueError("Path must stay inside this repository.")
|
||||
if full.suffix == ".org" and (full.is_relative_to(BLOGS_DIR) or full.is_relative_to(POSTS_DIR)):
|
||||
if full.name in GENERATED_CONTENT_NAMES or "sync-conflict" in full.name:
|
||||
raise ValueError("Generated and sync-conflict files are not editable here.")
|
||||
rel_parts = full.relative_to(ROOT).parts
|
||||
if any(part in EXCLUDED_CONTENT_DIR_NAMES for part in rel_parts):
|
||||
raise ValueError("This path is outside the editable content folders.")
|
||||
if full.suffix == ".org":
|
||||
return full
|
||||
if full.suffix == ".md" and full.is_relative_to(LIMA_DIR):
|
||||
return full
|
||||
raise ValueError("Only .org files under blogs/posts and .md files under lima can be edited here.")
|
||||
return full
|
||||
raise ValueError("Only .org content files and .md files under lima can be edited here.")
|
||||
|
||||
|
||||
def safe_target_path(path: str, slug: str, page_type: str) -> Path:
|
||||
@@ -310,7 +359,12 @@ def read_page(path: Path) -> ContentPage:
|
||||
in_header = False
|
||||
body_lines.append(line)
|
||||
rel = path.relative_to(ROOT).as_posix()
|
||||
page_type = "blog" if path.is_relative_to(BLOGS_DIR) else "post"
|
||||
if path.is_relative_to(BLOGS_DIR):
|
||||
page_type = "blog"
|
||||
elif path.is_relative_to(POSTS_DIR):
|
||||
page_type = "post"
|
||||
else:
|
||||
page_type = "page"
|
||||
slug = meta.get("SLUG") or path.stem
|
||||
tags = normalise_tags(meta.get("FILETAGS", ""))
|
||||
return ContentPage(
|
||||
@@ -346,33 +400,34 @@ def page_to_dict(page: ContentPage) -> dict[str, Any]:
|
||||
|
||||
def list_pages() -> list[dict[str, Any]]:
|
||||
pages = []
|
||||
for base, page_type, extension in (
|
||||
(BLOGS_DIR, "blog", "*.org"),
|
||||
(POSTS_DIR, "post", "*.org"),
|
||||
(LIMA_DIR, "lima", "*.md"),
|
||||
):
|
||||
if not base.exists():
|
||||
org_paths = []
|
||||
if ROOT.exists():
|
||||
for path in ROOT.rglob("*.org"):
|
||||
rel_parts = path.relative_to(ROOT).parts
|
||||
if any(part in EXCLUDED_CONTENT_DIR_NAMES for part in rel_parts):
|
||||
continue
|
||||
org_paths.append(path)
|
||||
md_paths = list(LIMA_DIR.rglob("*.md")) if LIMA_DIR.exists() else []
|
||||
for path in sorted(org_paths + md_paths):
|
||||
if path.name in GENERATED_CONTENT_NAMES or "sync-conflict" in path.name:
|
||||
continue
|
||||
for path in sorted(base.rglob(extension)):
|
||||
if path.name in GENERATED_CONTENT_NAMES or "sync-conflict" in path.name:
|
||||
continue
|
||||
try:
|
||||
page = read_page(path)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
parsed = parse_org_datetime(page.date)
|
||||
pages.append(
|
||||
{
|
||||
"path": page.path,
|
||||
"pageType": page_type,
|
||||
"title": page.title,
|
||||
"slug": page.slug,
|
||||
"tags": page.tags,
|
||||
"date": page.date,
|
||||
"format": page.format,
|
||||
"timestamp": parsed.timestamp() if parsed else path.stat().st_mtime,
|
||||
}
|
||||
)
|
||||
try:
|
||||
page = read_page(path)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
parsed = parse_org_datetime(page.date)
|
||||
pages.append(
|
||||
{
|
||||
"path": page.path,
|
||||
"pageType": page.page_type,
|
||||
"title": page.title,
|
||||
"slug": page.slug,
|
||||
"tags": page.tags,
|
||||
"date": page.date,
|
||||
"format": page.format,
|
||||
"timestamp": parsed.timestamp() if parsed else path.stat().st_mtime,
|
||||
}
|
||||
)
|
||||
return sorted(pages, key=lambda item: item["timestamp"], reverse=True)
|
||||
|
||||
|
||||
@@ -396,7 +451,9 @@ def target_path(data: dict[str, Any], existing_path: str | None) -> Path:
|
||||
return folder / f"{slug}.org"
|
||||
if page_type == "lima":
|
||||
return LIMA_DIR / f"{slug}.md"
|
||||
raise ValueError("pageType must be blog, post, or lima.")
|
||||
if page_type == "page":
|
||||
return ROOT / f"{slug}.org"
|
||||
raise ValueError("pageType must be blog, post, page, or lima.")
|
||||
|
||||
|
||||
def render_markdown(data: dict[str, Any]) -> str:
|
||||
@@ -669,9 +726,10 @@ APP_HTML = r"""<!doctype html>
|
||||
<div class="filters">
|
||||
<input id="search" type="search" placeholder="Filter pages" />
|
||||
<select id="typeFilter">
|
||||
<option value="">Blogs and posts</option>
|
||||
<option value="">All files</option>
|
||||
<option value="blog">Blogs</option>
|
||||
<option value="post">Posts</option>
|
||||
<option value="page">Pages</option>
|
||||
<option value="lima">Lima</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -688,6 +746,7 @@ APP_HTML = r"""<!doctype html>
|
||||
<select name="pageType">
|
||||
<option value="blog">Blog</option>
|
||||
<option value="post">Post</option>
|
||||
<option value="page">Page</option>
|
||||
<option value="lima">Lima</option>
|
||||
</select>
|
||||
</label>
|
||||
@@ -819,11 +878,14 @@ APP_HTML = r"""<!doctype html>
|
||||
const query = $("#search").value.toLowerCase();
|
||||
const type = $("#typeFilter").value;
|
||||
pagesBox.innerHTML = "";
|
||||
state.pages
|
||||
const matches = state.pages
|
||||
.filter((page) => (!type || page.pageType === type))
|
||||
.filter((page) => `${page.title} ${page.path} ${page.tags.join(" ")}`.toLowerCase().includes(query))
|
||||
.slice(0, 120)
|
||||
.forEach((page) => {
|
||||
.slice(0, 120);
|
||||
if (!matches.length) {
|
||||
pagesBox.innerHTML = `<div class="queue-item"><span>No files matched.</span></div>`;
|
||||
}
|
||||
matches.forEach((page) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "page-item";
|
||||
@@ -938,8 +1000,12 @@ APP_HTML = r"""<!doctype html>
|
||||
}
|
||||
|
||||
async function refreshPages() {
|
||||
state.pages = await api("/api/pages");
|
||||
renderPages();
|
||||
try {
|
||||
state.pages = await api("/api/pages");
|
||||
renderPages();
|
||||
} catch (err) {
|
||||
pagesBox.innerHTML = `<div class="queue-item"><span>${html(err.message)}</span></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshBuild() {
|
||||
@@ -1002,6 +1068,10 @@ APP_HTML = r"""<!doctype html>
|
||||
editor.targetPath.value = `lima/${slug}.md`;
|
||||
return;
|
||||
}
|
||||
if (editor.pageType.value === "page") {
|
||||
editor.targetPath.value = `${slug}.org`;
|
||||
return;
|
||||
}
|
||||
if (editor.pageType.value === "post") {
|
||||
const section = slugify(editor.section.value || "");
|
||||
editor.targetPath.value = section === "untitled" ? `posts/${slug}.org` : `posts/${section}/${slug}.org`;
|
||||
@@ -1299,6 +1369,7 @@ def main() -> None:
|
||||
port = int(os.environ.get("AUTHOR_PORT", "8765"))
|
||||
server = ThreadingHTTPServer(("127.0.0.1", port), Handler)
|
||||
print(f"Authoring UI running at http://127.0.0.1:{port}")
|
||||
print(f"Content root: {ROOT}")
|
||||
print("Press Ctrl-C to stop.")
|
||||
try:
|
||||
server.serve_forever()
|
||||
|
||||
Reference in New Issue
Block a user