changes
All checks were successful
Build Org Website / build (push) Successful in 52s

This commit is contained in:
2026-05-07 16:21:41 +01:00
parent 3d87e3d1e8
commit c65df7d95a
17 changed files with 140 additions and 81 deletions

View File

@@ -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()