"""@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 → importlib loads doc_builder/render_.py calls render(filepath) → returns HTML string No 'as' → default code viewer with line numbers Each project provides its own render_.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'
' f'@import file not found: {import_path}' f'
' ) # .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 → 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'
' f'Renderer script doc_builder/{renderer_name} not found. ' f'Showing default code view. ' f'Create this file with a render(filepath) function ' f'to customize rendering.' f'
' ) return note + _render_default(fp) except Exception as e: return ( f'
' f'@import renderer error ({import_path} as {renderer_name}): {e}' f'
{traceback.format_exc()}
' f'
' ) 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_.py script does not exist.""" pass def _load_renderer(name, base_dir): """Load a renderer script from doc_builder/. 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 '
Cannot display binary file.
' from html import escape ext = filepath.suffix.lstrip('.').upper() html = [ f'
', f'
{filepath.name} ({len(lines)} lines)
', f'
',
    ]
    for i, line in enumerate(lines, 1):
        # escape HTML but keep the line content
        escaped = escape(line.rstrip('\n\r'))
        html.append(
            f'{i:4d} '
            f'{escaped}'
        )
    html.append('
') return '\n'.join(html)