153 lines
4.8 KiB
Python
153 lines
4.8 KiB
Python
"""@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)
|