···318{option}`services.systemd.akkoma.serviceConfig.BindPaths` and
319{option}`services.systemd.akkoma.serviceConfig.BindReadOnlyPaths` permit access to outside paths
320through bind mounts. Refer to
321-[{manpage}`systemd.exec(5)`](https://www.freedesktop.org/software/systemd/man/systemd.exec.html#BindPaths=)
322-for details.
323324### Distributed deployment {#modules-services-akkoma-distributed-deployment}
325
···318{option}`services.systemd.akkoma.serviceConfig.BindPaths` and
319{option}`services.systemd.akkoma.serviceConfig.BindReadOnlyPaths` permit access to outside paths
320through bind mounts. Refer to
321+[`BindPaths=`](https://www.freedesktop.org/software/systemd/man/systemd.exec.html#BindPaths=)
322+of {manpage}`systemd.exec(5)` for details.
323324### Distributed deployment {#modules-services-akkoma-distributed-deployment}
325
+1-1
nixos/modules/system/boot/networkd.nix
···1948 Extra command-line arguments to pass to systemd-networkd-wait-online.
1949 These also affect per-interface `systemd-network-wait-online@` services.
19501951- See [{manpage}`systemd-networkd-wait-online.service(8)`](https://www.freedesktop.org/software/systemd/man/systemd-networkd-wait-online.service.html) for all available options.
1952 '';
1953 type = with types; listOf str;
1954 default = [];
···1948 Extra command-line arguments to pass to systemd-networkd-wait-online.
1949 These also affect per-interface `systemd-network-wait-online@` services.
19501951+ See {manpage}`systemd-networkd-wait-online.service(8)` for all available options.
1952 '';
1953 type = with types; listOf str;
1954 default = [];
···1+from __future__ import annotations
2+3+import dataclasses as dc
4+import html
5+import itertools
6+7+from typing import cast, get_args, Iterable, Literal, Sequence
8+9+from markdown_it.token import Token
10+11+from .utils import Freezeable
12+13+# FragmentType is used to restrict structural include blocks.
14+FragmentType = Literal['preface', 'part', 'chapter', 'section', 'appendix']
15+16+# in the TOC all fragments are allowed, plus the all-encompassing book.
17+TocEntryType = Literal['book', 'preface', 'part', 'chapter', 'section', 'appendix']
18+19+def is_include(token: Token) -> bool:
20+ return token.type == "fence" and token.info.startswith("{=include=} ")
21+22+# toplevel file must contain only the title headings and includes, anything else
23+# would cause strange rendering.
24+def _check_book_structure(tokens: Sequence[Token]) -> None:
25+ for token in tokens[6:]:
26+ if not is_include(token):
27+ assert token.map
28+ raise RuntimeError(f"unexpected content in line {token.map[0] + 1}, "
29+ "expected structural include")
30+31+# much like books, parts may not contain headings other than their title heading.
32+# this is a limitation of the current renderers and TOC generators that do not handle
33+# this case well even though it is supported in docbook (and probably supportable
34+# anywhere else).
35+def _check_part_structure(tokens: Sequence[Token]) -> None:
36+ _check_fragment_structure(tokens)
37+ for token in tokens[3:]:
38+ if token.type == 'heading_open':
39+ assert token.map
40+ raise RuntimeError(f"unexpected heading in line {token.map[0] + 1}")
41+42+# two include blocks must either be adjacent or separated by a heading, otherwise
43+# we cannot generate a correct TOC (since there'd be nothing to link to between
44+# the two includes).
45+def _check_fragment_structure(tokens: Sequence[Token]) -> None:
46+ for i, token in enumerate(tokens):
47+ if is_include(token) \
48+ and i + 1 < len(tokens) \
49+ and not (is_include(tokens[i + 1]) or tokens[i + 1].type == 'heading_open'):
50+ assert token.map
51+ raise RuntimeError(f"unexpected content in line {token.map[0] + 1}, "
52+ "expected heading or structural include")
53+54+def check_structure(kind: TocEntryType, tokens: Sequence[Token]) -> None:
55+ wanted = { 'h1': 'title' }
56+ wanted |= { 'h2': 'subtitle' } if kind == 'book' else {}
57+ for (i, (tag, role)) in enumerate(wanted.items()):
58+ if len(tokens) < 3 * (i + 1):
59+ raise RuntimeError(f"missing {role} ({tag}) heading")
60+ token = tokens[3 * i]
61+ if token.type != 'heading_open' or token.tag != tag:
62+ assert token.map
63+ raise RuntimeError(f"expected {role} ({tag}) heading in line {token.map[0] + 1}", token)
64+ for t in tokens[3 * len(wanted):]:
65+ if t.type != 'heading_open' or not (role := wanted.get(t.tag, '')):
66+ continue
67+ assert t.map
68+ raise RuntimeError(
69+ f"only one {role} heading ({t.markup} [text...]) allowed per "
70+ f"{kind}, but found a second in line {t.map[0] + 1}. "
71+ "please remove all such headings except the first or demote the subsequent headings.",
72+ t)
73+74+ last_heading_level = 0
75+ for token in tokens:
76+ if token.type != 'heading_open':
77+ continue
78+79+ # book subtitle headings do not need an id, only book title headings do.
80+ # every other headings needs one too. we need this to build a TOC and to
81+ # provide stable links if the manual changes shape.
82+ if 'id' not in token.attrs and (kind != 'book' or token.tag != 'h2'):
83+ assert token.map
84+ raise RuntimeError(f"heading in line {token.map[0] + 1} does not have an id")
85+86+ level = int(token.tag[1:]) # because tag = h1..h6
87+ if level > last_heading_level + 1:
88+ assert token.map
89+ raise RuntimeError(f"heading in line {token.map[0] + 1} skips one or more heading levels, "
90+ "which is currently not allowed")
91+ last_heading_level = level
92+93+ if kind == 'book':
94+ _check_book_structure(tokens)
95+ elif kind == 'part':
96+ _check_part_structure(tokens)
97+ else:
98+ _check_fragment_structure(tokens)
99+100+@dc.dataclass(frozen=True)
101+class XrefTarget:
102+ id: str
103+ """link label for `[](#local-references)`"""
104+ title_html: str
105+ """toc label"""
106+ toc_html: str | None
107+ """text for `<title>` tags and `title="..."` attributes"""
108+ title: str | None
109+ """path to file that contains the anchor"""
110+ path: str
111+ """whether to drop the `#anchor` from links when expanding xrefs"""
112+ drop_fragment: bool = False
113+114+ def href(self) -> str:
115+ path = html.escape(self.path, True)
116+ return path if self.drop_fragment else f"{path}#{html.escape(self.id, True)}"
117+118+@dc.dataclass
119+class TocEntry(Freezeable):
120+ kind: TocEntryType
121+ target: XrefTarget
122+ parent: TocEntry | None = None
123+ prev: TocEntry | None = None
124+ next: TocEntry | None = None
125+ children: list[TocEntry] = dc.field(default_factory=list)
126+ starts_new_chunk: bool = False
127+128+ @property
129+ def root(self) -> TocEntry:
130+ return self.parent.root if self.parent else self
131+132+ @classmethod
133+ def of(cls, token: Token) -> TocEntry:
134+ entry = token.meta.get('TocEntry')
135+ if not isinstance(entry, TocEntry):
136+ raise RuntimeError('requested toc entry, none found', token)
137+ return entry
138+139+ @classmethod
140+ def collect_and_link(cls, xrefs: dict[str, XrefTarget], tokens: Sequence[Token]) -> TocEntry:
141+ result = cls._collect_entries(xrefs, tokens, 'book')
142+143+ def flatten_with_parent(this: TocEntry, parent: TocEntry | None) -> Iterable[TocEntry]:
144+ this.parent = parent
145+ return itertools.chain([this], *[ flatten_with_parent(c, this) for c in this.children ])
146+147+ flat = list(flatten_with_parent(result, None))
148+ prev = flat[0]
149+ prev.starts_new_chunk = True
150+ paths_seen = set([prev.target.path])
151+ for c in flat[1:]:
152+ if prev.target.path != c.target.path and c.target.path not in paths_seen:
153+ c.starts_new_chunk = True
154+ c.prev, prev.next = prev, c
155+ prev = c
156+ paths_seen.add(c.target.path)
157+158+ for c in flat:
159+ c.freeze()
160+161+ return result
162+163+ @classmethod
164+ def _collect_entries(cls, xrefs: dict[str, XrefTarget], tokens: Sequence[Token],
165+ kind: TocEntryType) -> TocEntry:
166+ # we assume that check_structure has been run recursively over the entire input.
167+ # list contains (tag, entry) pairs that will collapse to a single entry for
168+ # the full sequence.
169+ entries: list[tuple[str, TocEntry]] = []
170+ for token in tokens:
171+ if token.type.startswith('included_') and (included := token.meta.get('included')):
172+ fragment_type_str = token.type[9:].removesuffix('s')
173+ assert fragment_type_str in get_args(TocEntryType)
174+ fragment_type = cast(TocEntryType, fragment_type_str)
175+ for fragment, _path in included:
176+ entries[-1][1].children.append(cls._collect_entries(xrefs, fragment, fragment_type))
177+ elif token.type == 'heading_open' and (id := cast(str, token.attrs.get('id', ''))):
178+ while len(entries) > 1 and entries[-1][0] >= token.tag:
179+ entries[-2][1].children.append(entries.pop()[1])
180+ entries.append((token.tag,
181+ TocEntry(kind if token.tag == 'h1' else 'section', xrefs[id])))
182+ token.meta['TocEntry'] = entries[-1][1]
183+184+ while len(entries) > 1:
185+ entries[-2][1].children.append(entries.pop()[1])
186+ return entries[0][1]
···1+from typing import Any
2+3+_frozen_classes: dict[type, type] = {}
4+5+# make a derived class freezable (ie, disallow modifications).
6+# we do this by changing the class of an instance at runtime when freeze()
7+# is called, providing a derived class that is exactly the same except
8+# for a __setattr__ that raises an error when called. this beats having
9+# a field for frozenness and an unconditional __setattr__ that checks this
10+# field because it does not insert anything into the class dict.
11+class Freezeable:
12+ def freeze(self) -> None:
13+ cls = type(self)
14+ if not (frozen := _frozen_classes.get(cls)):
15+ def __setattr__(instance: Any, n: str, v: Any) -> None:
16+ raise TypeError(f'{cls.__name__} is frozen')
17+ frozen = type(cls.__name__, (cls,), {
18+ '__setattr__': __setattr__,
19+ })
20+ _frozen_classes[cls] = frozen
21+ self.__class__ = frozen
···1-import nixos_render_docs
23from sample_md import sample1
4···67import markdown_it
89-class Converter(nixos_render_docs.md.Converter):
10- __renderer__ = nixos_render_docs.commonmark.CommonMarkRenderer
001112# NOTE: in these tests we represent trailing spaces by ` ` and replace them with real space later,
13# since a number of editors will strip trailing whitespace on save and that would break the tests.
···1+import nixos_render_docs as nrd
23from sample_md import sample1
4···67import markdown_it
89+class Converter(nrd.md.Converter[nrd.commonmark.CommonMarkRenderer]):
10+ def __init__(self, manpage_urls: Mapping[str, str]):
11+ super().__init__()
12+ self._renderer = nrd.commonmark.CommonMarkRenderer(manpage_urls)
1314# NOTE: in these tests we represent trailing spaces by ` ` and replace them with real space later,
15# since a number of editors will strip trailing whitespace on save and that would break the tests.