1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
#!/usr/bin/env python3
"""
Preprocesses mdbook markdown, primarily for include directives.

The include directive format is as follows:
    {{#include foo/bar/baz.md}}

The content of includes will be indented as much as the directive itself.

Including a generated file (from building Lix; generally for the 'new' CLI):
    {{#include @generated@/foo/bar/baz.md}}
"""

from pathlib import Path
import json
import os, os.path
import sys
import textwrap

name = 'substitute.py'

def log(*args, **kwargs):
    kwargs['file'] = sys.stderr
    return print(f'{name}:', *args, **kwargs)

def remove_prefix_if_present(s: str, prefix: str) -> str | None:
    if s.startswith(prefix):
        return s.removeprefix(prefix)
    else:
        return None

def do_include(content: str, relative_md_path: Path, source_root: Path, search_path: Path):
    assert not relative_md_path.is_absolute(), f'{relative_md_path=} from mdbook should be relative'

    md_path_abs = source_root / relative_md_path
    var_abs = md_path_abs.parent
    assert var_abs.is_dir(), f'supposed directory {var_abs} is not a directory (cwd={os.getcwd()})'

    lines = []
    for l in content.splitlines(keepends=True):
        if remain := remove_prefix_if_present(l.strip(), "{{#include"):
            requested = remain[1:-2]
            # We indent bodies of indent directives by the indent of the
            # directive itself.
            num_leading_indent = len(l) - len(l.lstrip())

            if subpath := remove_prefix_if_present(requested, "@generated@/"):
                included = search_path / Path(subpath)
                requested = included.relative_to(search_path)
            else:
                included = source_root / relative_md_path.parent / requested
                requested = included.resolve().relative_to(source_root)
            assert included.exists(), f"{requested} not found at {included}"

            lines.append(
                textwrap.indent(
                    do_include(
                        included.read_text(),
                        requested,
                        source_root,
                        search_path,
                    ),
                    " " * num_leading_indent
                )
                + "\n"
            )
        else:
            lines.append(l)
    return "".join(lines)

def recursive_replace(data, book_root, search_path):
    match data:
        # XXX FUTURE: drop sections once mdBook is at 0.5.0 or above in nixpkgs
        case {'sections': sections}:
            return data | dict(
                sections = [recursive_replace(section, book_root, search_path) for section in sections],
            )
        case {'items': items}:
            return data | dict(
                items = [recursive_replace(item, book_root, search_path) for item in items],
            )
        case {'Chapter': chapter}:
            path_to_chapter = Path(chapter['path'])
            chapter_content = chapter['content']

            return data | dict(
                Chapter = chapter | dict(
                    # first process includes. this must happen before docroot processing since
                    # mdbook does not see these included files, only the final agglomeration.
                    content = do_include(
                        chapter_content,
                        path_to_chapter,
                        book_root,
                        search_path
                    ).replace(
                        '@docroot@',
                        ("../" * len(path_to_chapter.parent.parts) or "./")[:-1]
                    ).replace(
                        # this replacement is to avoid corrupting the
                        # hacking.md manual section on docroot
                        '@\\docroot\\@',
                        '@docroot@',
                    ),
                    sub_items = [
                        recursive_replace(sub_item, book_root, search_path)
                        for sub_item in chapter['sub_items']
                    ],
                ),
            )

        case rest:
            assert False, f'should have been called on a dict, not {type(rest)=}\n\t{rest=}'

def main():

    if len(sys.argv) > 1 and sys.argv[1] == 'supports':
        return 0

    # mdbook communicates with us over stdin and stdout.
    # It splorks us a JSON array, the first element describing the context,
    # the second element describing the book itself,
    # and then expects us to send it the modified book JSON over stdout.

    context, book = json.load(sys.stdin)

    # book_root is the directory where book contents leave (ie, src/)
    book_root = Path(context['root']) / context['config']['book'].get('src', 'src')

    # includes pointing into @generated@ will look here
    search_path = Path(os.environ['MANUAL_SUBSTITUTE_SEARCH'])

    # Find @var@ in all parts of our recursive book structure.
    replaced_content = recursive_replace(book, book_root, search_path)

    replaced_content_str = json.dumps(replaced_content)

    # Give mdbook our changes.
    print(replaced_content_str)

try:
    sys.exit(main())
except AssertionError as e:
    print(f'{name}: INTERNAL ERROR in mdbook preprocessor', file=sys.stderr)
    print(f'this is a bug in {name}', file=sys.stderr)
    raise