rbpu_datasheet/doc_builder/import_handler.py

153 lines
4.8 KiB
Python
Raw Permalink Normal View History

2026-07-19 20:56:15 +08:00
"""@import handler — thin dispatcher with plugin architecture.
Syntax:
@import "data/file.csv" default: code display with line numbers
@import "data/file.csv" as table load doc_builder/render_table.py, call render()
@import "data/file.json" as registers load doc_builder/render_registers.py
@import "data/file.yaml" as requirements load doc_builder/render_requirements.py
How 'as' works:
as <name> importlib loads doc_builder/render_<name>.py
calls render(filepath) returns HTML string
No 'as' default code viewer with line numbers
Each project provides its own render_<name>.py scripts in doc_builder/.
Built-in renderers (table, bga, address, registers, requirements) are
included and can be copied between projects.
"""
import importlib
import re
import traceback
from pathlib import Path
IMPORT_RE = re.compile(
r'^@import\s+"([^"]+)"(?:\s+using\s+([\w.]+))?\s*$',
re.MULTILINE,
)
def handle(import_path, renderer_name, base_dir, process_chapter_fn):
"""Resolve @import and dispatch to renderer.
Args:
import_path: file path from @import directive
renderer_name: name after 'as' (e.g. 'table', 'registers'), or None
base_dir: project root for path resolution
process_chapter_fn: callback for recursive .md processing
Returns:
HTML string
"""
fp = (base_dir / import_path).resolve()
if not fp.exists():
return (
f'<div class="warning">'
f'@import file not found: {import_path}'
f'</div>'
)
# .md files are always processed recursively (no renderer script)
if fp.suffix.lower() == '.md':
md_text = fp.read_text(encoding='utf-8')
return process_chapter_fn(md_text)
# No as specified → default: code display with line numbers
if not renderer_name:
return _render_default(fp)
# as <name> → load renderer script and call render()
try:
renderer = _load_renderer(renderer_name, base_dir)
return renderer(fp)
except RendererNotFound:
# Graceful fallback: show default code view + note
note = (
f'<div class="note">'
f'Renderer script <code>doc_builder/{renderer_name}</code> not found. '
f'Showing default code view. '
f'Create this file with a <code>render(filepath)</code> function '
f'to customize rendering.'
f'</div>'
)
return note + _render_default(fp)
except Exception as e:
return (
f'<div class="warning">'
f'@import renderer error ({import_path} as {renderer_name}): {e}'
f'<pre>{traceback.format_exc()}</pre>'
f'</div>'
)
def process_imports(md_text, base_dir, process_chapter_fn):
"""Replace all @import directives in md_text with rendered HTML."""
def _replace(match):
path = match.group(1)
hint = match.group(2) # may be None
return handle(path, hint, base_dir, process_chapter_fn)
return IMPORT_RE.sub(_replace, md_text)
# ---- Renderer plugin loader ----
class RendererNotFound(Exception):
"""Raised when a render_<name>.py script does not exist."""
pass
def _load_renderer(name, base_dir):
"""Load a renderer script from doc_builder/<name>.
The module must export: render(filepath) HTML string
"""
doc_builder = base_dir / 'doc_builder'
script = doc_builder / name
if not script.exists():
raise RendererNotFound(
f'doc_builder/{name} not found'
)
spec = importlib.util.spec_from_file_location(
name.replace('.', '_'), str(script)
)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
if not hasattr(mod, 'render'):
raise RendererNotFound(
f'doc_builder/{name} has no render() function'
)
return mod.render
# ---- Default renderer: code with line numbers ----
def _render_default(filepath):
"""Display file content as code with line numbers."""
try:
with open(filepath, encoding='utf-8') as f:
lines = f.readlines()
except UnicodeDecodeError:
return '<div class="warning">Cannot display binary file.</div>'
from html import escape
ext = filepath.suffix.lstrip('.').upper()
html = [
f'<div class="code-block">',
f'<div class="code-header">{filepath.name} ({len(lines)} lines)</div>',
f'<pre class="code-lines">',
]
for i, line in enumerate(lines, 1):
# escape HTML but keep the line content
escaped = escape(line.rstrip('\n\r'))
html.append(
f'<span class="ln">{i:4d}</span> '
f'<span class="lc">{escaped}</span>'
)
html.append('</pre></div>')
return '\n'.join(html)