StrictDoc Documentation
strictdoc/export/html/html_generator.py
Source file coverage
Path:
strictdoc/export/html/html_generator.py
Lines:
783
Non-empty lines:
701
Non-empty lines covered with requirements:
701 / 701 (100.0%)
Functions:
16
Functions covered by requirements:
16 / 16 (100.0%)
1
import os
2
import shutil
3
from collections import defaultdict
4
from functools import partial
5
from pathlib import Path
6
from typing import Any, Dict, List, Optional, Set, Tuple
7
 
8
import orjson
9
from html2pdf4doc import PATH_TO_HTML2PDF4DOC_JS
10
 
11
from strictdoc.backend.sdoc.models.document import SDocDocument
12
from strictdoc.core.asset_manager import AssetDir
13
from strictdoc.core.document_meta import DocumentMeta
14
from strictdoc.core.file_system.source_tree import SourceTree
15
from strictdoc.core.project_config import ProjectConfig, ProjectFeature
16
from strictdoc.core.traceability_index import TraceabilityIndex
17
from strictdoc.export.html.document_type import DocumentType
18
from strictdoc.export.html.generators.document import DocumentHTMLGenerator
19
from strictdoc.export.html.generators.document_table import (
20
    DocumentTableHTMLGenerator,
21
)
22
from strictdoc.export.html.html_templates import (
23
    HTMLTemplates,
24
    NormalHTMLTemplates,
25
)
26
from strictdoc.export.html.renderers.link_renderer import LinkRenderer
27
from strictdoc.export.html.renderers.markup_renderer import MarkupRenderer
28
from strictdoc.features.deep_trace.generator import (
29
    DocumentDeepTraceHTMLGenerator,
30
)
31
from strictdoc.features.html2pdf.generator import (
32
    DocumentHTML2PDFGenerator,
33
)
34
from strictdoc.features.project_index.generator import (
35
    DocumentTreeHTMLGenerator,
36
)
37
from strictdoc.features.project_index.project_map_generator import (
38
    ProjectMapGenerator,
39
)
40
from strictdoc.features.source_coverage.generator import (
41
    SourceFileCoverageHTMLGenerator,
42
)
43
from strictdoc.features.source_file_view.generator import (
44
    SourceFileViewHTMLGenerator,
45
)
46
from strictdoc.features.trace.generator import (
47
    DocumentTraceHTMLGenerator,
48
)
49
from strictdoc.features.traceability_matrix.generator import (
50
    TraceabilityMatrixHTMLGenerator,
51
)
52
from strictdoc.features.tree_map.generator import TreeMapGenerator
53
from strictdoc.helpers.cast import assert_cast
54
from strictdoc.helpers.file_modification_time import get_file_modification_time
55
from strictdoc.helpers.file_system import sync_dir
56
from strictdoc.helpers.git_client import GitClient
57
from strictdoc.helpers.mid import MID
58
from strictdoc.helpers.parallelizer import Parallelizer
59
from strictdoc.helpers.paths import SDocRelativePath, path_to_posix_path
60
from strictdoc.helpers.timing import measure_performance, timing_decorator
61
 
62
 
63
def render_favicon_svg(
64
    project_config: ProjectConfig,
65
    html_templates: HTMLTemplates,  # noqa: ARG001
66
) -> str:
67
    # Deliberately not using html_templates.jinja_environment(): for large
68
    # projects it is a CompiledHTMLTemplates instance that lazily caches a
69
    # ModuleLoader-backed Environment (holding unpicklable compiled
70
    # _TemplateModule objects) on first call. HTMLGenerator instances are
71
    # captured by the closure passed to the document-export parallelizer,
72
    # so populating that cache here, in the main process, before parallel
73
    # export starts, makes the whole HTMLGenerator (and its html_templates)
74
    # unpicklable for the worker pool. A standalone, uncached environment
75
    # sidesteps that entirely; favicon.svg.jinja is small enough that
76
    # skipping template compilation has no measurable cost.
77
    variant = project_config.get_favicon_variant()
78
    return (
79
        NormalHTMLTemplates()
80
        .jinja_environment()
81
        .get_template("_shared/favicon.svg.jinja")
82
        .render(variant=variant)
83
    )
84
 
85
 
86
class HTMLGenerator:
87
    def __init__(
88
        self, project_config: ProjectConfig, html_templates: HTMLTemplates
89
    ):
90
        self.project_config: ProjectConfig = project_config
91
        self.html_templates = html_templates
92
        self.git_client: GitClient = GitClient()
93
 
94
    def export_complete_tree(
95
        self,
96
        *,
97
        traceability_index: TraceabilityIndex,
98
        parallelizer: Parallelizer,
99
    ) -> None:
100
        Path(self.project_config.export_output_html_root).mkdir(
101
            parents=True, exist_ok=True
102
        )
103
 
104
        # Export assets.
105
        HTMLGenerator.export_assets(
106
            traceability_index=traceability_index,
107
            project_config=self.project_config,
108
            html_templates=self.html_templates,
109
            export_output_html_root=self.project_config.export_output_html_root,
110
        )
111
 
112
        # Export static search index.
113
        self.export_static_html_search_index(
114
            traceability_index=traceability_index
115
        )
116
 
117
        # Export all documents in parallel.
118
        export_binding = partial(
119
            self.export_single_document_with_performance,
120
            traceability_index=traceability_index,
121
        )
122
 
123
        # By default, do not export included documents. Only, if the option to
124
        # include is provided.
125
        documents_to_export: List[SDocDocument] = []
126
 
127
        if self.project_config.export_included_documents:
128
            documents_to_export[:] = (
129
                traceability_index.document_tree.document_list
130
            )
131
        else:
132
            for document_ in traceability_index.document_tree.document_list:
133
                if document_.document_is_included():
134
                    continue
135
 
136
                document_meta = assert_cast(document_.meta, DocumentMeta)
137
 
138
                input_doc_full_path = document_meta.input_doc_full_path
139
                output_doc_full_path = document_meta.output_document_full_path
140
 
141
                if os.path.isfile(output_doc_full_path) and (
142
                    get_file_modification_time(input_doc_full_path)
143
                    < get_file_modification_time(output_doc_full_path)
144
                    and not traceability_index.file_dependency_manager.must_generate(
145
                        document_meta.output_document_full_path
146
                    )
147
                ):
148
                    with measure_performance(f"Skip: {document_.title}"):
149
                        continue
150
 
151
                documents_to_export.append(document_)
152
 
153
        if len(documents_to_export) > 0:
154
            if len(traceability_index.document_tree.document_list) <= 25:
155
                parallelizer.run_parallel(documents_to_export, export_binding)
156
            else:
157
                print(  # noqa: T201
158
                    "NOTE: Running document export without parallelization "
159
                    "because the document tree contains more than 25 documents."
160
                )
161
                for document_ in documents_to_export:
162
                    export_binding(document_)
163
 
164
        # Export document tree.
165
        # FIXME: It is important that this export is **after** the parallelized
166
        # export of single documents. It turns out that Jinja does not play
167
        # well with the multiprocessing's processed-based parallelization.
168
        # _pickle.PicklingError: Can't pickle <function sync_do_first at 0x1077bdf80>: it's not the same object as jinja2.filters.sync_do_first.
169
        self.export_project_tree_screen(traceability_index=traceability_index)
170
 
171
        # Export JavaScript map of the document tree (project map)
172
        self.export_project_map(traceability_index=traceability_index)
173
 
174
        if self.project_config.is_activated_tree_map():
175
            self.export_tree_map_screen(traceability_index)
176
 
177
        # Project statistics is exported by the ExportAction class via the
178
        # Feature abstraction (see the ProjectStatisticsFeature class), not
179
        # here.
180
 
181
        # Export requirements coverage.
182
        if self.project_config.is_feature_activated(
183
            ProjectFeature.TRACEABILITY_MATRIX_SCREEN
184
        ):
185
            self.export_requirements_coverage_screen(
186
                traceability_index=traceability_index,
187
            )
188
 
189
        # Export source coverage.
190
        if self.project_config.is_feature_activated(
191
            ProjectFeature.REQUIREMENT_TO_SOURCE_TRACEABILITY
192
        ):
193
            self.export_source_files_screens(
194
                traceability_index=traceability_index,
195
            )
196
            self.export_source_coverage_screen(
197
                traceability_index=traceability_index,
198
            )
199
 
200
        print(  # noqa: T201
201
            "Export completed. Documentation tree can be found at:\n"
202
            f"{self.project_config.export_output_html_root}"
203
        )
204
 
205
    @staticmethod
206
    def export_assets(
207
        *,
208
        traceability_index: Optional[TraceabilityIndex],
209
        project_config: ProjectConfig,
210
        html_templates: HTMLTemplates,
211
        export_output_html_root: str,
212
        flat_assets: bool = False,
213
    ) -> None:
214
        """
215
        Copy all assets to output dir during HTML/PDF generation.
216
 
217
        :param bool flat_assets: This parameter is always set to False except when
218
                                 exporting a "bundle document" with HTML2PDF.
219
                                 The bundle document contains all documents of
220
                                 the documentation tree. In this case, all assets
221
                                 are simply copied to the top level _assets folder,
222
                                 independently on how nested the contained documents are.
223
        """
224
 
225
        # Export StrictDoc's own assets.
226
        output_html_static_files = os.path.join(
227
            export_output_html_root,
228
            project_config.dir_for_sdoc_assets,
229
        )
230
        for static_files_path in project_config.get_static_files_paths():
231
            sync_dir(
232
                static_files_path,
233
                output_html_static_files,
234
                message="Copying StrictDoc's assets",
235
            )
236
 
237
        # Write the favicon: a project's own custom file (only for the
238
        # "default" variant, see ProjectConfig.get_custom_favicon_path()),
239
        # or else render it from the Jinja template so it can encode which
240
        # kind of StrictDoc instance (dev/test/docs export) rendered it.
241
        favicon_output_path = os.path.join(
242
            output_html_static_files, project_config.get_favicon_filename()
243
        )
244
        custom_favicon_path = project_config.get_custom_favicon_path()
245
        if custom_favicon_path is not None:
246
            shutil.copyfile(custom_favicon_path, favicon_output_path)
247
        else:
248
            favicon_svg = render_favicon_svg(project_config, html_templates)
249
            with open(favicon_output_path, "w", encoding="utf8") as output_file:
250
                output_file.write(favicon_svg)
251
 
252
        # Export HTML2PDF.
253
        if project_config.is_feature_activated(ProjectFeature.HTML2PDF):
254
            sync_dir(
255
                os.path.dirname(PATH_TO_HTML2PDF4DOC_JS),
256
                output_html_static_files,
257
                message="Copying HTML2PDF.js",
258
            )
259
 
260
        # Export custom html2pdf template.
261
        if project_config.html2pdf_template is not None:
262
            output_custom_html2pdf_template = os.path.join(
263
                export_output_html_root,
264
                project_config.dir_for_sdoc_assets,
265
                "html2pdf_template",
266
            )
267
            sync_dir(
268
                os.path.abspath(
269
                    os.path.dirname(project_config.html2pdf_template)
270
                ),
271
                output_custom_html2pdf_template,
272
                message="Copying Custom HTML2PDF template assets",
273
            )
274
 
275
        # Export project's assets.
276
 
277
        if traceability_index is not None:
278
            redundant_assets: Dict[str, List[SDocRelativePath]] = {}
279
            for document_ in traceability_index.document_tree.document_list:
280
                assert document_.meta is not None
281
                for (
282
                    included_document_
283
                ) in document_.iterate_included_documents_depth_first():
284
                    assert included_document_.meta is not None
285
 
286
                    redundant_assets.setdefault(
287
                        document_.meta.input_doc_assets_dir_rel_path.relative_path_posix,
288
                        [],
289
                    )
290
                    redundant_assets[
291
                        document_.meta.input_doc_assets_dir_rel_path.relative_path_posix
292
                    ].append(
293
                        included_document_.meta.input_doc_assets_dir_rel_path
294
                    )
295
 
296
            assert traceability_index.asset_manager is not None
297
 
298
            asset_dir_: AssetDir
299
            for asset_dir_ in traceability_index.asset_manager.iterate():
300
                source_path = asset_dir_.full_path
301
                output_relative_path = asset_dir_.relative_path
302
 
303
                destination_path = os.path.join(
304
                    export_output_html_root,
305
                    output_relative_path.relative_path
306
                    if not flat_assets
307
                    else "_assets",
308
                )
309
 
310
                sync_dir(
311
                    source_path,
312
                    destination_path,
313
                    message=f'Copying project assets "{output_relative_path.relative_path}"',
314
                )
315
                redundant_asset_paths = redundant_assets.get(
316
                    output_relative_path.relative_path_posix
317
                )
318
                if redundant_asset_paths is not None:
319
                    for redundant_asset_ in redundant_asset_paths:
320
                        destination_path = os.path.join(
321
                            export_output_html_root,
322
                            redundant_asset_.relative_path
323
                            if not flat_assets
324
                            else "_assets",
325
                        )
326
                        sync_dir(
327
                            source_path,
328
                            destination_path,
329
                            message=f'Copying project assets "{output_relative_path.relative_path}"',
330
                        )
331
 
332
    def export_single_document_with_performance(
333
        self,
334
        document: SDocDocument,
335
        traceability_index: TraceabilityIndex,
336
        specific_documents: Optional[Tuple[DocumentType, ...]] = None,
337
    ) -> None:
338
        if specific_documents is None:
339
            specific_documents = DocumentType.all()
340
 
341
        with measure_performance(f"Published: {document.title}"):
342
            self.export_single_document(
343
                document,
344
                traceability_index,
345
                specific_documents=specific_documents,
346
            )
347
 
348
    def export_single_document(
349
        self,
350
        document: SDocDocument,
351
        traceability_index: TraceabilityIndex,
352
        specific_documents: Optional[Tuple[DocumentType, ...]] = None,
353
    ) -> SDocDocument:
354
        if document.config.layout == "Website":
355
            specific_documents = (DocumentType.DOCUMENT,)
356
        elif specific_documents is None:
357
            specific_documents = DocumentType.all()
358
 
359
        assert document.meta is not None
360
 
361
        document_meta: DocumentMeta = document.meta
362
 
363
        document_output_folder = document_meta.output_document_dir_full_path
364
        Path(document_output_folder).mkdir(parents=True, exist_ok=True)
365
 
366
        root_path = document.meta.get_root_path_prefix()
367
        link_renderer = LinkRenderer(
368
            root_path=root_path,
369
            static_path=self.project_config.dir_for_sdoc_assets,
370
        )
371
        markup_renderer = MarkupRenderer.create(
372
            document.config.markup,
373
            traceability_index,
374
            link_renderer,
375
            self.html_templates,
376
            self.project_config,
377
            document,
378
        )
379
 
380
        if DocumentType.DOCUMENT in specific_documents:
381
            # Single Document pages.
382
            document_content = DocumentHTMLGenerator.export(
383
                self.project_config,
384
                document,
385
                traceability_index,
386
                markup_renderer,
387
                link_renderer,
388
                git_client=self.git_client,
389
                html_templates=self.html_templates,
390
            )
391
            document_out_file = document_meta.get_html_doc_path()
392
            with open(document_out_file, "w", encoding="utf8") as file:
393
                file.write(document_content)
394
 
395
        # Single Document Table pages.
396
        if (
397
            self.project_config.is_feature_activated(
398
                ProjectFeature.TABLE_SCREEN
399
            )
400
            and DocumentType.TABLE in specific_documents
401
        ):
402
            document_content = DocumentTableHTMLGenerator.export(
403
                self.project_config,
404
                document,
405
                traceability_index,
406
                markup_renderer,
407
                link_renderer,
408
                git_client=self.git_client,
409
                html_templates=self.html_templates,
410
            )
411
            document_out_file = document_meta.get_html_table_path()
412
            with open(document_out_file, "w", encoding="utf8") as file:
413
                file.write(document_content)
414
 
415
        # Single Document Traceability pages.
416
        if (
417
            self.project_config.is_feature_activated(
418
                ProjectFeature.TRACEABILITY_SCREEN
419
            )
420
            and DocumentType.TRACE in specific_documents
421
        ):
422
            document_content = DocumentTraceHTMLGenerator.export(
423
                self.project_config,
424
                document,
425
                traceability_index,
426
                markup_renderer,
427
                link_renderer,
428
                git_client=self.git_client,
429
                html_templates=self.html_templates,
430
            )
431
            document_out_file = document_meta.get_html_traceability_path()
432
            with open(document_out_file, "w", encoding="utf8") as file:
433
                file.write(document_content)
434
 
435
        # Single Document Deep Traceability pages.
436
        if (
437
            self.project_config.is_feature_activated(
438
                ProjectFeature.DEEP_TRACEABILITY_SCREEN
439
            )
440
            and DocumentType.DEEPTRACE in specific_documents
441
        ):
442
            document_content = DocumentDeepTraceHTMLGenerator.export_deep(
443
                self.project_config,
444
                document,
445
                traceability_index,
446
                markup_renderer,
447
                link_renderer,
448
                git_client=self.git_client,
449
                html_templates=self.html_templates,
450
            )
451
            document_out_file = document_meta.get_html_deep_traceability_path()
452
            with open(document_out_file, "w", encoding="utf8") as file:
453
                file.write(document_content)
454
 
455
        # Single Document PDF pages.
456
        if (
457
            self.project_config.is_feature_activated(ProjectFeature.HTML2PDF)
458
            and DocumentType.PDF in specific_documents
459
        ):
460
            document_content = DocumentHTML2PDFGenerator.export(
461
                self.project_config,
462
                document,
463
                traceability_index,
464
                markup_renderer,
465
                link_renderer,
466
                git_client=self.git_client,
467
                html_templates=self.html_templates,
468
            )
469
            document_out_file = document_meta.get_html_pdf_path()
470
            with open(document_out_file, "w", encoding="utf8") as file:
471
                file.write(document_content)
472
 
473
        return document
474
 
475
    def export_project_tree_screen(
476
        self,
477
        *,
478
        traceability_index: TraceabilityIndex,
479
    ) -> None:
480
        Path(self.project_config.export_output_html_root).mkdir(
481
            parents=True, exist_ok=True
482
        )
483
        output_file = os.path.join(
484
            self.project_config.export_output_html_root, "index.html"
485
        )
486
        writer = DocumentTreeHTMLGenerator()
487
        output = writer.export(
488
            self.project_config,
489
            traceability_index=traceability_index,
490
            html_templates=self.html_templates,
491
        )
492
        with open(output_file, "w", encoding="utf8") as file:
493
            file.write(output)
494
 
495
    def export_project_map(
496
        self,
497
        *,
498
        traceability_index: TraceabilityIndex,
499
    ) -> None:
500
        assets_dir = os.path.join(
501
            self.project_config.export_output_html_root,
502
            self.project_config.dir_for_sdoc_assets,
503
        )
504
        output_file = os.path.join(assets_dir, "project_map.js")
505
        writer = ProjectMapGenerator()
506
        output = writer.export(
507
            self.project_config,
508
            traceability_index=traceability_index,
509
            html_templates=self.html_templates,
510
        )
511
        with open(output_file, "w", encoding="utf8") as file:
512
            file.write(output)
513
 
514
    def export_requirements_coverage_screen(
515
        self,
516
        *,
517
        traceability_index: TraceabilityIndex,
518
    ) -> None:
519
        requirements_coverage_content = TraceabilityMatrixHTMLGenerator.export(
520
            project_config=self.project_config,
521
            traceability_index=traceability_index,
522
            html_templates=self.html_templates,
523
        )
524
        output_html_requirements_coverage = os.path.join(
525
            self.project_config.export_output_html_root,
526
            "traceability_matrix.html",
527
        )
528
        with open(
529
            output_html_requirements_coverage, "w", encoding="utf8"
530
        ) as file:
531
            file.write(requirements_coverage_content)
532
 
533
    @timing_decorator("Export source file pages")
534
    def export_source_files_screens(
535
        self,
536
        *,
537
        traceability_index: TraceabilityIndex,
538
    ) -> None:
539
        assert isinstance(
540
            traceability_index.document_tree.source_tree, SourceTree
541
        ), traceability_index.document_tree.source_tree
542
        print("Generating source files:")  # noqa: T201
543
        for (
544
            source_file
545
        ) in traceability_index.document_tree.source_tree.source_files:
546
            if not source_file.is_referenced:
547
                continue
548
 
549
            SourceFileViewHTMLGenerator.export_to_file(
550
                project_config=self.project_config,
551
                source_file=source_file,
552
                traceability_index=traceability_index,
553
                html_templates=self.html_templates,
554
            )
555
 
556
    def export_source_coverage_screen(
557
        self,
558
        *,
559
        traceability_index: TraceabilityIndex,
560
    ) -> None:
561
        assert isinstance(
562
            traceability_index.document_tree.source_tree, SourceTree
563
        ), traceability_index.document_tree.source_tree
564
 
565
        source_coverage_content = SourceFileCoverageHTMLGenerator.export(
566
            project_config=self.project_config,
567
            traceability_index=traceability_index,
568
            html_templates=self.html_templates,
569
        )
570
        output_html_source_coverage = os.path.join(
571
            self.project_config.export_output_html_root, "source_coverage.html"
572
        )
573
        with open(output_html_source_coverage, "w", encoding="utf8") as file:
574
            file.write(source_coverage_content)
575
 
576
    def export_single_source_file_screen(
577
        self,
578
        *,
579
        traceability_index: TraceabilityIndex,
580
        path_to_source_file: str,
581
    ) -> None:
582
        assert isinstance(
583
            traceability_index.document_tree.source_tree, SourceTree
584
        ), traceability_index.document_tree.source_tree
585
 
586
        # FIXME: path_to_source_file must not enter this function with forward slashes.
587
        #        Test and fix this on Windows.
588
        #        https://github.com/strictdoc-project/strictdoc/issues/2068
589
        relative_path_to_source_file = path_to_posix_path(path_to_source_file)
590
        relative_path_to_source_file = (
591
            relative_path_to_source_file.removeprefix("_source_files/")
592
        )
593
        relative_path_to_source_file = (
594
            relative_path_to_source_file.removesuffix(".html")
595
        )
596
 
597
        for (
598
            source_file
599
        ) in traceability_index.document_tree.source_tree.source_files:
600
            if not source_file.is_referenced:
601
                continue
602
 
603
            if (
604
                relative_path_to_source_file
605
                == source_file.in_doctree_source_file_rel_path_posix
606
            ):
607
                SourceFileViewHTMLGenerator.export_to_file(
608
                    project_config=self.project_config,
609
                    source_file=source_file,
610
                    traceability_index=traceability_index,
611
                    html_templates=self.html_templates,
612
                )
613
                return
614
 
615
        raise FileNotFoundError
616
 
617
    @timing_decorator("Export static HTML search index")
618
    def export_static_html_search_index(
619
        self,
620
        traceability_index: TraceabilityIndex,
621
        *,
622
        force_regeneration: bool = False,
623
    ) -> None:
624
        """
625
        Export a static search index as dictionaries in .js files.
626
 
627
        @relation(SDOC-SRS-155, scope=function)
628
        @relation(SDOC-SRS-156, scope=function)
629
        """
630
 
631
        if not force_regeneration:
632
            # First check if there is nothing to do because no documents have
633
            # been changed or regenerated.
634
 
635
            # FIXME: This is wrong. FIX!
636
            must_regenerate = (
637
                len(traceability_index.document_tree.document_list) == 0
638
            )
639
 
640
            for document_ in traceability_index.document_tree.document_list:
641
                assert document_.meta is not None
642
                if traceability_index.file_dependency_manager.must_generate(
643
                    document_.meta.output_document_full_path
644
                ):
645
                    must_regenerate = True
646
                    break
647
 
648
            if not must_regenerate:
649
                print(  # noqa: T201
650
                    "All documents are up-to-date. "
651
                    "Skipping the generation of a search index."
652
                )
653
                # If no documents need to be regenerated, set the
654
                # search_index_timestamp to the timestamp of the first document.
655
                # The HTML/JS code can rely on this timestamp to decide whether
656
                # it has to re-read the search index from the JS file or it can
657
                # fetch it from the DB.
658
                if len(traceability_index.document_tree.document_list) > 0:
659
                    first_document = (
660
                        traceability_index.document_tree.document_list[0]
661
                    )
662
                    assert first_document.meta is not None
663
                    traceability_index.search_index_timestamp = (
664
                        get_file_modification_time(
665
                            first_document.meta.input_doc_full_path
666
                        )
667
                    )
668
                return
669
 
670
        if force_regeneration:
671
            for document_ in traceability_index.document_tree.document_list:
672
                document_.build_search_index()
673
 
674
        global_index: Dict[str, Set[int]] = defaultdict(set)
675
        global_map_nodes_by_mid: Dict[int, Dict[str, str]] = {}
676
 
677
        document_index_list: List[Dict[str, Set[str]]] = []
678
        document_map_list: List[Dict[int, Dict[str, str]]] = []
679
 
680
        map_mid_to_numbers: Dict[str, int] = {}
681
 
682
        with measure_performance("Build search index"):
683
            for document_ in traceability_index.document_tree.document_list:
684
                assert document_.meta is not None
685
                document_index_list.append(
686
                    document_.search_index.document_index
687
                )
688
                map_nodes_by_numbers: Dict[int, Dict[str, str]] = {}
689
                for (
690
                    node_mid_,
691
                    node_dict_,
692
                ) in document_.search_index.map_nodes_by_mid.items():
693
                    if node_mid_ not in map_mid_to_numbers:
694
                        map_mid_to_numbers[node_mid_] = (
695
                            len(map_mid_to_numbers) + 1
696
                        )
697
                    document_mid_number = map_mid_to_numbers[node_mid_]
698
                    assert isinstance(document_mid_number, int)
699
                    map_nodes_by_numbers[document_mid_number] = node_dict_
700
 
701
                document_map_list.append(map_nodes_by_numbers)
702
            for document_index_ in document_index_list:
703
                for term_, document_mids_ in document_index_.items():
704
                    document_mid_numbers = set()
705
                    for document_mid_ in document_mids_:
706
                        document_mid_number = map_mid_to_numbers[document_mid_]
707
                        document_mid_numbers.add(document_mid_number)
708
                    global_index[term_].update(document_mid_numbers)
709
            for map_nodes_by_mid_ in document_map_list:
710
                global_map_nodes_by_mid.update(map_nodes_by_mid_)
711
 
712
        link_renderer = LinkRenderer(
713
            root_path="",
714
            static_path=self.project_config.dir_for_sdoc_assets,
715
        )
716
        for _, node_ in global_map_nodes_by_mid.items():
717
            # When running on server, the MID is used as a link to the node.
718
            # The MID is then resolved to the correct URL by the server when
719
            # requested at /UID/{uid_or_mid}.
720
            # This ensures that all nodes can be reached with MID, including
721
            # the nodes that don't have a UID.
722
            if self.project_config.is_running_on_server:
723
                node_["_LINK"] = node_["MID"]
724
 
725
            # When running static HTML, the resolution of _LINKs happens through
726
            # the auto-generated static JS project_map.js that has a format of:
727
            # {<local anchor>: MID}
728
            else:
729
                node = traceability_index.get_node_by_mid(MID(node_["MID"]))
730
                node_["_LINK"] = link_renderer.render_local_anchor(node)
731
 
732
        def default(obj: Any) -> Any:
733
            if isinstance(obj, set):
734
                return list(obj)
735
            raise TypeError
736
 
737
        with measure_performance("Serialize search index to JS"):
738
            document_content = (
739
                b"window.StrictDoc = window.StrictDoc || {};\n"
740
                b"window.StrictDoc.search = window.StrictDoc.search || {};\n"
741
                b"window.StrictDoc.search.index = "
742
                + orjson.dumps(
743
                    global_index,
744
                    option=orjson.OPT_NON_STR_KEYS,
745
                    default=default,
746
                )
747
                + b";\n\n"
748
            )
749
 
750
        with measure_performance("Serialize lookup map {MID => node} to JS"):
751
            document_content += (
752
                b"window.StrictDoc.search.nodesByMid = "
753
                + orjson.dumps(
754
                    global_map_nodes_by_mid, option=orjson.OPT_NON_STR_KEYS
755
                )
756
                + b";\n"
757
            )
758
 
759
        # Export StrictDoc's own assets.
760
        output_html_static_files = os.path.join(
761
            self.project_config.export_output_html_root,
762
            self.project_config.dir_for_sdoc_assets,
763
        )
764
        output_html_source_coverage = os.path.join(
765
            output_html_static_files,
766
            "static_html_search_index.js",
767
        )
768
        with open(output_html_source_coverage, "wb") as file:
769
            file.write(document_content)
770
 
771
        traceability_index.search_index_timestamp = get_file_modification_time(
772
            output_html_source_coverage
773
        )
774
 
775
    def export_tree_map_screen(
776
        self,
777
        traceability_index: TraceabilityIndex,
778
    ) -> None:
779
        TreeMapGenerator.export(
780
            project_config=self.project_config,
781
            traceability_index=traceability_index,
782
            html_templates=self.html_templates,
783
        )