247 lines
7.7 KiB
Python
247 lines
7.7 KiB
Python
"""
|
|
Service layer untuk Docs Viewer — rendering markdown dokumentasi teknis.
|
|
|
|
Pola mtime-cache yang sama dengan lesson_service._read_md_cached:
|
|
- Cache berdasarkan mtime file — segar bila file diubah tanpa restart.
|
|
- Jangan gunakan lru_cache untuk file-based content (mtime bisa berubah).
|
|
- Path traversal protection: hanya file di dalam DOCS_DIR yang boleh dibaca.
|
|
|
|
Frontmatter parsing:
|
|
- YAML frontmatter (---\ntitle: ...\norder: N\ncategory: ...\n---).
|
|
- Fallback bila frontmatter tidak ada/rusak: urutkan berdasarkan prefix angka
|
|
nama file, gunakan H1 pertama sebagai title.
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import threading
|
|
|
|
import markdown as md
|
|
from bleach import clean as bleach_clean
|
|
|
|
from config import CONTENT_DIR
|
|
|
|
# Docs directory — berada di root proyek, sejajar dengan content/
|
|
_DOCS_DIR = os.path.abspath(
|
|
os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "docs")
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cache
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_file_cache = {} # {path: {'content': str, 'mtime': float}}
|
|
_file_cache_lock = threading.Lock()
|
|
|
|
_docs_index_cache = None # [(filename, metadata, mtime)]
|
|
_docs_index_lock = threading.Lock()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Frontmatter parsing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_FRONTMATTER_RE = re.compile(r'^---\s*\n(.*?)\n---\s*\n', re.DOTALL)
|
|
|
|
|
|
def _parse_frontmatter(filename):
|
|
"""Parse frontmatter dari file docs/<filename>.md.
|
|
|
|
Returns dict dengan keys: title, order, category, body (markdown tanpa frontmatter).
|
|
Jika frontmatter tidak ada atau rusak, gunakan fallback:
|
|
- title: H1 pertama di body
|
|
- order: prefix angka dari nama file (01-13)
|
|
- category: "general"
|
|
"""
|
|
file_path = os.path.join(_DOCS_DIR, filename)
|
|
|
|
# Path traversal protection
|
|
abs_path = os.path.abspath(file_path)
|
|
if not abs_path.startswith(_DOCS_DIR + os.sep) and abs_path != _DOCS_DIR:
|
|
return None
|
|
|
|
current_mtime = os.path.getmtime(file_path) if os.path.exists(file_path) else None
|
|
|
|
with _file_cache_lock:
|
|
cached = _file_cache.get(abs_path)
|
|
if cached and current_mtime is not None and cached.get("mtime") == current_mtime:
|
|
return cached["data"]
|
|
|
|
if not os.path.exists(file_path):
|
|
return None
|
|
|
|
try:
|
|
with open(file_path, "r", encoding="utf-8") as f:
|
|
content = f.read()
|
|
except (OSError, PermissionError) as e:
|
|
print(f"Warning: Could not read {file_path}: {e}")
|
|
return None
|
|
|
|
|
|
# Parse frontmatter
|
|
title = None
|
|
order = None
|
|
category = "general"
|
|
|
|
fm_match = _FRONTMATTER_RE.match(content)
|
|
if fm_match:
|
|
fm_text = fm_match.group(1)
|
|
body = content[fm_match.end():]
|
|
for line in fm_text.splitlines():
|
|
if ":" in line:
|
|
key, _, val = line.partition(":")
|
|
key = key.strip()
|
|
val = val.strip()
|
|
if key == "title":
|
|
title = val
|
|
elif key == "order":
|
|
try:
|
|
order = int(val)
|
|
except ValueError:
|
|
order = None
|
|
elif key == "category":
|
|
category = val
|
|
else:
|
|
body = content
|
|
|
|
# Fallback: title dari H1, order dari prefix angka nama file
|
|
if not title:
|
|
for line in body.splitlines():
|
|
if line.startswith("# "):
|
|
title = line[2:].strip()
|
|
break
|
|
if title is None:
|
|
title = filename.replace(".md", "").replace("_", " ").title()
|
|
|
|
if order is None:
|
|
# Extract leading digits from filename
|
|
m = re.match(r'(\d+)', filename)
|
|
if m:
|
|
order = int(m.group(1))
|
|
|
|
result = {
|
|
"title": title,
|
|
"order": order,
|
|
"category": category,
|
|
"body": body,
|
|
}
|
|
|
|
with _file_cache_lock:
|
|
_file_cache[abs_path] = {"content": content, "mtime": current_mtime, "data": result}
|
|
|
|
return result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Docs index (list all docs files, sorted by order)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def get_docs_index():
|
|
"""Return list of docs metadata, sorted by order then filename.
|
|
|
|
Cached mtime-aware: rescans and refreshes when any docs/*.md changes.
|
|
"""
|
|
if not os.path.isdir(_DOCS_DIR):
|
|
return []
|
|
|
|
# Collect mtimes of all docs/*.md files to detect cache invalidation
|
|
docs_files = []
|
|
current_mtimes = {}
|
|
try:
|
|
for entry in os.scandir(_DOCS_DIR):
|
|
if entry.is_file() and entry.name.endswith(".md"):
|
|
slug = entry.name[:-3] # strip .md
|
|
docs_files.append((slug, entry.path))
|
|
try:
|
|
current_mtimes[entry.path] = entry.stat().st_mtime
|
|
except OSError:
|
|
current_mtimes[entry.path] = 0.0
|
|
except OSError:
|
|
return []
|
|
|
|
with _docs_index_lock:
|
|
global _docs_index_cache
|
|
# Check if cache is still valid
|
|
if _docs_index_cache is not None:
|
|
cached_mtimes, cached_list = _docs_index_cache
|
|
if cached_mtimes == current_mtimes:
|
|
return cached_list
|
|
|
|
# Rebuild index
|
|
results = []
|
|
for slug, path in docs_files:
|
|
meta = _parse_frontmatter(os.path.basename(path))
|
|
if meta is None:
|
|
continue
|
|
|
|
# Path traversal — double-check slug doesn't escape
|
|
safe_path = os.path.abspath(path)
|
|
if not safe_path.startswith(_DOCS_DIR + os.sep):
|
|
continue
|
|
|
|
results.append({
|
|
"slug": slug,
|
|
"title": meta["title"],
|
|
"order": meta["order"],
|
|
"category": meta["category"],
|
|
})
|
|
|
|
# Sort by order, then filename
|
|
results.sort(key=lambda x: (x["order"] if x["order"] is not None else 9999, x["slug"]))
|
|
|
|
_docs_index_cache = (dict(current_mtimes), results)
|
|
return results
|
|
|
|
|
|
def get_doc_content(slug):
|
|
"""Return rendered HTML + metadata for a single doc by slug.
|
|
|
|
Returns dict: {title, order, category, html, markdown}
|
|
Returns None if not found or path traversal attempt.
|
|
"""
|
|
# Path traversal protection
|
|
safe_slug = re.sub(r'[^a-zA-Z0-9_\-]', '', slug)
|
|
file_path = os.path.join(_DOCS_DIR, safe_slug + ".md")
|
|
|
|
abs_path = os.path.abspath(file_path)
|
|
if not abs_path.startswith(_DOCS_DIR + os.sep):
|
|
return None
|
|
if not os.path.exists(file_path):
|
|
return None
|
|
|
|
meta = _parse_frontmatter(os.path.basename(file_path))
|
|
if meta is None:
|
|
return None
|
|
|
|
# Render markdown → HTML (reuse MD_EXTENSIONS from lesson_service for consistency)
|
|
from services.lesson_service import MD_EXTENSIONS
|
|
html = md.markdown(meta["body"], extensions=MD_EXTENSIONS)
|
|
|
|
# Sanitize: allow common markdown HTML tags, strip scripts
|
|
html = bleach_clean(
|
|
html,
|
|
tags=[
|
|
"p", "br", "strong", "em", "u", "code", "pre", "blockquote",
|
|
"h1", "h2", "h3", "h4", "h5", "h6",
|
|
"ul", "ol", "li", "a", "img", "table", "thead", "tbody",
|
|
"tr", "th", "td", "hr", "span", "div", "kbd", "var",
|
|
],
|
|
attributes={
|
|
"a": ["href", "title", "target", "rel"],
|
|
"img": ["src", "alt", "title"],
|
|
"code": ["class"],
|
|
"span": ["class"],
|
|
"div": ["class"],
|
|
"pre": ["class"],
|
|
},
|
|
strip=True,
|
|
)
|
|
|
|
return {
|
|
"title": meta["title"],
|
|
"order": meta["order"],
|
|
"category": meta["category"],
|
|
"html": html,
|
|
}
|