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