StrictDoc Documentation
strictdoc/export/html/html_generator.py
Source file coverage
Path:
strictdoc/export/html/html_generator.py
Lines:
873
Non-empty lines:
783
Non-empty lines covered with requirements:
783 / 783 (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_deep_trace import (
21
    DocumentDeepTraceHTMLGenerator,
22
)
23
from strictdoc.export.html.generators.document_table import (
24
    DocumentTableHTMLGenerator,
25
)
26
from strictdoc.export.html.generators.document_trace import (
27
    DocumentTraceHTMLGenerator,
28
)
29
from strictdoc.export.html.generators.document_tree import (
30
    DocumentTreeHTMLGenerator,
31
)
32
from strictdoc.export.html.generators.project_map import (
33
    ProjectMapGenerator,
34
)
35
from strictdoc.export.html.generators.source_file_coverage import (
36
    SourceFileCoverageHTMLGenerator,
37
)
38
from strictdoc.export.html.generators.source_file_view_generator import (
39
    SourceFileViewHTMLGenerator,
40
)
41
from strictdoc.export.html.generators.traceability_matrix import (
42
    TraceabilityMatrixHTMLGenerator,
43
)
44
from strictdoc.export.html.html_templates import HTMLTemplates
45
from strictdoc.export.html.renderers.link_renderer import LinkRenderer
46
from strictdoc.export.html.renderers.markup_renderer import MarkupRenderer
47
from strictdoc.features.html2pdf.generator import (
48
    DocumentHTML2PDFGenerator,
49
)
50
from strictdoc.features.project_statistics.generator import (
51
    ProgressStatisticsGenerator,
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
        sync_dir(
210
            project_config.get_static_files_path(),
211
            output_html_static_files,
212
            message="Copying StrictDoc's assets",
213
        )
214
 
215
        # Export MathJax.
216
        if project_config.is_feature_activated(ProjectFeature.MATHJAX):
217
            output_html_mathjax = os.path.join(
218
                export_output_html_root,
219
                project_config.dir_for_sdoc_assets,
220
                "mathjax",
221
            )
222
            Path(output_html_mathjax).mkdir(parents=True, exist_ok=True)
223
            mathjax_src = os.path.join(
224
                project_config.get_extra_static_files_path(), "mathjax"
225
            )
226
            sync_dir(
227
                mathjax_src,
228
                output_html_mathjax,
229
                message="Copying MathJax assets",
230
            )
231
 
232
        # Export Mermaid.
233
        if project_config.is_feature_activated(ProjectFeature.MERMAID):
234
            output_html_mathjax = os.path.join(
235
                export_output_html_root,
236
                project_config.dir_for_sdoc_assets,
237
                "mermaid",
238
            )
239
            Path(output_html_mathjax).mkdir(parents=True, exist_ok=True)
240
            mermaid_src = os.path.join(
241
                project_config.get_extra_static_files_path(), "mermaid"
242
            )
243
            sync_dir(
244
                mermaid_src,
245
                output_html_mathjax,
246
                message="Copying Mermaid assets",
247
            )
248
 
249
        # Export Rapidoc.
250
        if project_config.is_feature_activated(ProjectFeature.RAPIDOC):
251
            output_html_rapidoc = os.path.join(
252
                export_output_html_root,
253
                project_config.dir_for_sdoc_assets,
254
                "rapidoc",
255
            )
256
            Path(output_html_rapidoc).mkdir(parents=True, exist_ok=True)
257
            rapidoc_src = os.path.join(
258
                project_config.get_extra_static_files_path(), "rapidoc"
259
            )
260
            sync_dir(
261
                rapidoc_src,
262
                output_html_rapidoc,
263
                message="Copying Rapidoc assets",
264
            )
265
 
266
        # Export NESTOR.
267
        if project_config.is_feature_activated(ProjectFeature.NESTOR):
268
            output_html_nestor = os.path.join(
269
                export_output_html_root,
270
                project_config.dir_for_sdoc_assets,
271
                "nestor",
272
            )
273
            Path(output_html_nestor).mkdir(parents=True, exist_ok=True)
274
            mathjax_src = os.path.join(
275
                project_config.get_extra_static_files_path(), "nestor"
276
            )
277
            sync_dir(
278
                mathjax_src,
279
                output_html_nestor,
280
                message="Copying Nestor assets",
281
            )
282
 
283
        # Export HTML2PDF.
284
        if project_config.is_feature_activated(ProjectFeature.HTML2PDF):
285
            sync_dir(
286
                os.path.dirname(PATH_TO_HTML2PDF4DOC_JS),
287
                output_html_static_files,
288
                message="Copying HTML2PDF.js",
289
            )
290
 
291
        # Export custom html2pdf template.
292
        if project_config.html2pdf_template is not None:
293
            output_custom_html2pdf_template = os.path.join(
294
                export_output_html_root,
295
                project_config.dir_for_sdoc_assets,
296
                "html2pdf_template",
297
            )
298
            sync_dir(
299
                os.path.abspath(
300
                    os.path.dirname(project_config.html2pdf_template)
301
                ),
302
                output_custom_html2pdf_template,
303
                message="Copying Custom HTML2PDF template assets",
304
            )
305
 
306
        # Export project's assets.
307
 
308
        if traceability_index is not None:
309
            redundant_assets: Dict[str, List[SDocRelativePath]] = {}
310
            for document_ in traceability_index.document_tree.document_list:
311
                assert document_.meta is not None
312
                for (
313
                    included_document_
314
                ) in document_.iterate_included_documents_depth_first():
315
                    assert included_document_.meta is not None
316
 
317
                    redundant_assets.setdefault(
318
                        document_.meta.input_doc_assets_dir_rel_path.relative_path_posix,
319
                        [],
320
                    )
321
                    redundant_assets[
322
                        document_.meta.input_doc_assets_dir_rel_path.relative_path_posix
323
                    ].append(
324
                        included_document_.meta.input_doc_assets_dir_rel_path
325
                    )
326
 
327
            assert traceability_index.asset_manager is not None
328
 
329
            asset_dir_: AssetDir
330
            for asset_dir_ in traceability_index.asset_manager.iterate():
331
                source_path = asset_dir_.full_path
332
                output_relative_path = asset_dir_.relative_path
333
 
334
                destination_path = os.path.join(
335
                    export_output_html_root,
336
                    output_relative_path.relative_path
337
                    if not flat_assets
338
                    else "_assets",
339
                )
340
 
341
                sync_dir(
342
                    source_path,
343
                    destination_path,
344
                    message=f'Copying project assets "{output_relative_path.relative_path}"',
345
                )
346
                redundant_asset_paths = redundant_assets.get(
347
                    output_relative_path.relative_path_posix
348
                )
349
                if redundant_asset_paths is not None:
350
                    for redundant_asset_ in redundant_asset_paths:
351
                        destination_path = os.path.join(
352
                            export_output_html_root,
353
                            redundant_asset_.relative_path
354
                            if not flat_assets
355
                            else "_assets",
356
                        )
357
                        sync_dir(
358
                            source_path,
359
                            destination_path,
360
                            message=f'Copying project assets "{output_relative_path.relative_path}"',
361
                        )
362
 
363
    def export_single_document_with_performance(
364
        self,
365
        document: SDocDocument,
366
        traceability_index: TraceabilityIndex,
367
        specific_documents: Optional[Tuple[DocumentType, ...]] = None,
368
    ) -> None:
369
        if specific_documents is None:
370
            specific_documents = DocumentType.all()
371
 
372
        with measure_performance(f"Published: {document.title}"):
373
            self.export_single_document(
374
                document,
375
                traceability_index,
376
                specific_documents=specific_documents,
377
            )
378
 
379
    def export_single_document(
380
        self,
381
        document: SDocDocument,
382
        traceability_index: TraceabilityIndex,
383
        specific_documents: Optional[Tuple[DocumentType, ...]] = None,
384
    ) -> SDocDocument:
385
        if document.config.layout == "Website":
386
            specific_documents = (DocumentType.DOCUMENT,)
387
        elif specific_documents is None:
388
            specific_documents = DocumentType.all()
389
 
390
        assert document.meta is not None
391
 
392
        document_meta: DocumentMeta = document.meta
393
 
394
        document_output_folder = document_meta.output_document_dir_full_path
395
        Path(document_output_folder).mkdir(parents=True, exist_ok=True)
396
 
397
        root_path = document.meta.get_root_path_prefix()
398
        link_renderer = LinkRenderer(
399
            root_path=root_path,
400
            static_path=self.project_config.dir_for_sdoc_assets,
401
        )
402
        markup_renderer = MarkupRenderer.create(
403
            document.config.markup,
404
            traceability_index,
405
            link_renderer,
406
            self.html_templates,
407
            self.project_config,
408
            document,
409
        )
410
 
411
        if DocumentType.DOCUMENT in specific_documents:
412
            # Single Document pages.
413
            document_content = DocumentHTMLGenerator.export(
414
                self.project_config,
415
                document,
416
                traceability_index,
417
                markup_renderer,
418
                link_renderer,
419
                git_client=self.git_client,
420
                html_templates=self.html_templates,
421
            )
422
            document_out_file = document_meta.get_html_doc_path()
423
            with open(document_out_file, "w", encoding="utf8") as file:
424
                file.write(document_content)
425
 
426
        # Single Document Table pages.
427
        if (
428
            self.project_config.is_feature_activated(
429
                ProjectFeature.TABLE_SCREEN
430
            )
431
            and DocumentType.TABLE in specific_documents
432
        ):
433
            document_content = DocumentTableHTMLGenerator.export(
434
                self.project_config,
435
                document,
436
                traceability_index,
437
                markup_renderer,
438
                link_renderer,
439
                git_client=self.git_client,
440
                html_templates=self.html_templates,
441
            )
442
            document_out_file = document_meta.get_html_table_path()
443
            with open(document_out_file, "w", encoding="utf8") as file:
444
                file.write(document_content)
445
 
446
        # Single Document Traceability pages.
447
        if (
448
            self.project_config.is_feature_activated(
449
                ProjectFeature.TRACEABILITY_SCREEN
450
            )
451
            and DocumentType.TRACE in specific_documents
452
        ):
453
            document_content = DocumentTraceHTMLGenerator.export(
454
                self.project_config,
455
                document,
456
                traceability_index,
457
                markup_renderer,
458
                link_renderer,
459
                git_client=self.git_client,
460
                html_templates=self.html_templates,
461
            )
462
            document_out_file = document_meta.get_html_traceability_path()
463
            with open(document_out_file, "w", encoding="utf8") as file:
464
                file.write(document_content)
465
 
466
        # Single Document Deep Traceability pages.
467
        if (
468
            self.project_config.is_feature_activated(
469
                ProjectFeature.DEEP_TRACEABILITY_SCREEN
470
            )
471
            and DocumentType.DEEPTRACE in specific_documents
472
        ):
473
            document_content = DocumentDeepTraceHTMLGenerator.export_deep(
474
                self.project_config,
475
                document,
476
                traceability_index,
477
                markup_renderer,
478
                link_renderer,
479
                git_client=self.git_client,
480
                html_templates=self.html_templates,
481
            )
482
            document_out_file = document_meta.get_html_deep_traceability_path()
483
            with open(document_out_file, "w", encoding="utf8") as file:
484
                file.write(document_content)
485
 
486
        # Single Document PDF pages.
487
        if (
488
            self.project_config.is_feature_activated(ProjectFeature.HTML2PDF)
489
            and DocumentType.PDF in specific_documents
490
        ):
491
            document_content = DocumentHTML2PDFGenerator.export(
492
                self.project_config,
493
                document,
494
                traceability_index,
495
                markup_renderer,
496
                link_renderer,
497
                git_client=self.git_client,
498
                html_templates=self.html_templates,
499
            )
500
            document_out_file = document_meta.get_html_pdf_path()
501
            with open(document_out_file, "w", encoding="utf8") as file:
502
                file.write(document_content)
503
 
504
        return document
505
 
506
    def export_project_tree_screen(
507
        self,
508
        *,
509
        traceability_index: TraceabilityIndex,
510
    ) -> None:
511
        Path(self.project_config.export_output_html_root).mkdir(
512
            parents=True, exist_ok=True
513
        )
514
        output_file = os.path.join(
515
            self.project_config.export_output_html_root, "index.html"
516
        )
517
        writer = DocumentTreeHTMLGenerator()
518
        output = writer.export(
519
            self.project_config,
520
            traceability_index=traceability_index,
521
            html_templates=self.html_templates,
522
        )
523
        with open(output_file, "w", encoding="utf8") as file:
524
            file.write(output)
525
 
526
    def export_project_map(
527
        self,
528
        *,
529
        traceability_index: TraceabilityIndex,
530
    ) -> None:
531
        assets_dir = os.path.join(
532
            self.project_config.export_output_html_root,
533
            self.project_config.dir_for_sdoc_assets,
534
        )
535
        output_file = os.path.join(assets_dir, "project_map.js")
536
        writer = ProjectMapGenerator()
537
        output = writer.export(
538
            self.project_config,
539
            traceability_index=traceability_index,
540
            html_templates=self.html_templates,
541
        )
542
        with open(output_file, "w", encoding="utf8") as file:
543
            file.write(output)
544
 
545
    def export_requirements_coverage_screen(
546
        self,
547
        *,
548
        traceability_index: TraceabilityIndex,
549
    ) -> None:
550
        requirements_coverage_content = TraceabilityMatrixHTMLGenerator.export(
551
            project_config=self.project_config,
552
            traceability_index=traceability_index,
553
            html_templates=self.html_templates,
554
        )
555
        output_html_requirements_coverage = os.path.join(
556
            self.project_config.export_output_html_root,
557
            "traceability_matrix.html",
558
        )
559
        with open(
560
            output_html_requirements_coverage, "w", encoding="utf8"
561
        ) as file:
562
            file.write(requirements_coverage_content)
563
 
564
    @timing_decorator("Export source file pages")
565
    def export_source_files_screens(
566
        self,
567
        *,
568
        traceability_index: TraceabilityIndex,
569
    ) -> None:
570
        assert isinstance(
571
            traceability_index.document_tree.source_tree, SourceTree
572
        ), traceability_index.document_tree.source_tree
573
        print("Generating source files:")  # noqa: T201
574
        for (
575
            source_file
576
        ) in traceability_index.document_tree.source_tree.source_files:
577
            if not source_file.is_referenced:
578
                continue
579
 
580
            SourceFileViewHTMLGenerator.export_to_file(
581
                project_config=self.project_config,
582
                source_file=source_file,
583
                traceability_index=traceability_index,
584
                html_templates=self.html_templates,
585
            )
586
 
587
    def export_source_coverage_screen(
588
        self,
589
        *,
590
        traceability_index: TraceabilityIndex,
591
    ) -> None:
592
        assert isinstance(
593
            traceability_index.document_tree.source_tree, SourceTree
594
        ), traceability_index.document_tree.source_tree
595
 
596
        source_coverage_content = SourceFileCoverageHTMLGenerator.export(
597
            project_config=self.project_config,
598
            traceability_index=traceability_index,
599
            html_templates=self.html_templates,
600
        )
601
        output_html_source_coverage = os.path.join(
602
            self.project_config.export_output_html_root, "source_coverage.html"
603
        )
604
        with open(output_html_source_coverage, "w", encoding="utf8") as file:
605
            file.write(source_coverage_content)
606
 
607
    def export_single_source_file_screen(
608
        self,
609
        *,
610
        traceability_index: TraceabilityIndex,
611
        path_to_source_file: str,
612
    ) -> None:
613
        assert isinstance(
614
            traceability_index.document_tree.source_tree, SourceTree
615
        ), traceability_index.document_tree.source_tree
616
 
617
        # FIXME: path_to_source_file must not enter this function with forward slashes.
618
        #        Test and fix this on Windows.
619
        #        https://github.com/strictdoc-project/strictdoc/issues/2068
620
        relative_path_to_source_file = path_to_posix_path(path_to_source_file)
621
        relative_path_to_source_file = (
622
            relative_path_to_source_file.removeprefix("_source_files/")
623
        )
624
        relative_path_to_source_file = (
625
            relative_path_to_source_file.removesuffix(".html")
626
        )
627
 
628
        for (
629
            source_file
630
        ) in traceability_index.document_tree.source_tree.source_files:
631
            if not source_file.is_referenced:
632
                continue
633
 
634
            if (
635
                relative_path_to_source_file
636
                == source_file.in_doctree_source_file_rel_path_posix
637
            ):
638
                SourceFileViewHTMLGenerator.export_to_file(
639
                    project_config=self.project_config,
640
                    source_file=source_file,
641
                    traceability_index=traceability_index,
642
                    html_templates=self.html_templates,
643
                )
644
                return
645
 
646
        raise FileNotFoundError
647
 
648
    def export_project_statistics(
649
        self,
650
        traceability_index: TraceabilityIndex,
651
    ) -> None:
652
        """
653
        Export project statistics to a dedicated HTML page.
654
 
655
        @relation(SDOC-SRS-97, scope=function)
656
        @relation(SDOC-SRS-154, scope=function)
657
        """
658
 
659
        link_renderer = LinkRenderer(
660
            root_path="",
661
            static_path=self.project_config.dir_for_sdoc_assets,
662
        )
663
 
664
        statistics_generator = ProgressStatisticsGenerator
665
 
666
        if (
667
            custom_statistics_generator_path
668
            := self.project_config.statistics_generator
669
        ) is not None:
670
            # It is important to add the input folder to the import path.
671
            # Otherwise, the custom statistics generator may not be found.
672
            # In fact, a more reasonable path to add would be the project config
673
            # path, but since it is not maintained by ProjectConfig yet and
674
            # usually equals the input path, add the input path for
675
            # now.
676
            input_paths = self.project_config.input_paths
677
            assert input_paths is not None and len(input_paths) > 0, (
678
                "Expected a valid input path."
679
            )
680
            sys.path.insert(0, input_paths[0])
681
 
682
            module_path, class_name = custom_statistics_generator_path.rsplit(
683
                ".", 1
684
            )
685
            try:
686
                module = importlib.import_module(module_path)
687
                statistics_generator = getattr(module, class_name)
688
            except ModuleNotFoundError as module_not_found_error_:
689
                raise StrictDocException(
690
                    "Could not import a user-provided statistics generator: "
691
                    f"{module_not_found_error_}."
692
                ) from module_not_found_error_
693
 
694
        document_content = statistics_generator.export(
695
            self.project_config,
696
            traceability_index,
697
            link_renderer,
698
            html_templates=self.html_templates,
699
        )
700
        output_html_source_coverage = os.path.join(
701
            self.project_config.export_output_html_root,
702
            "project_statistics.html",
703
        )
704
        with open(output_html_source_coverage, "w", encoding="utf8") as file:
705
            file.write(document_content)
706
 
707
    @timing_decorator("Export static HTML search index")
708
    def export_static_html_search_index(
709
        self,
710
        traceability_index: TraceabilityIndex,
711
        *,
712
        force_regeneration: bool = False,
713
    ) -> None:
714
        """
715
        Export a static search index as dictionaries in .js files.
716
 
717
        @relation(SDOC-SRS-155, scope=function)
718
        @relation(SDOC-SRS-156, scope=function)
719
        """
720
 
721
        if not force_regeneration:
722
            # First check if there is nothing to do because no documents have
723
            # been changed or regenerated.
724
 
725
            # FIXME: This is wrong. FIX!
726
            must_regenerate = (
727
                len(traceability_index.document_tree.document_list) == 0
728
            )
729
 
730
            for document_ in traceability_index.document_tree.document_list:
731
                assert document_.meta is not None
732
                if traceability_index.file_dependency_manager.must_generate(
733
                    document_.meta.output_document_full_path
734
                ):
735
                    must_regenerate = True
736
                    break
737
 
738
            if not must_regenerate:
739
                print(  # noqa: T201
740
                    "All documents are up-to-date. "
741
                    "Skipping the generation of a search index."
742
                )
743
                # If no documents need to be regenerated, set the
744
                # search_index_timestamp to the timestamp of the first document.
745
                # The HTML/JS code can rely on this timestamp to decide whether
746
                # it has to re-read the search index from the JS file or it can
747
                # fetch it from the DB.
748
                if len(traceability_index.document_tree.document_list) > 0:
749
                    first_document = (
750
                        traceability_index.document_tree.document_list[0]
751
                    )
752
                    assert first_document.meta is not None
753
                    traceability_index.search_index_timestamp = (
754
                        get_file_modification_time(
755
                            first_document.meta.input_doc_full_path
756
                        )
757
                    )
758
                return
759
 
760
        if force_regeneration:
761
            for document_ in traceability_index.document_tree.document_list:
762
                document_.build_search_index()
763
 
764
        global_index: Dict[str, Set[int]] = defaultdict(set)
765
        global_map_nodes_by_mid: Dict[int, Dict[str, str]] = {}
766
 
767
        document_index_list: List[Dict[str, Set[str]]] = []
768
        document_map_list: List[Dict[int, Dict[str, str]]] = []
769
 
770
        map_mid_to_numbers: Dict[str, int] = {}
771
 
772
        with measure_performance("Build search index"):
773
            for document_ in traceability_index.document_tree.document_list:
774
                assert document_.meta is not None
775
                document_index_list.append(
776
                    document_.search_index.document_index
777
                )
778
                map_nodes_by_numbers: Dict[int, Dict[str, str]] = {}
779
                for (
780
                    node_mid_,
781
                    node_dict_,
782
                ) in document_.search_index.map_nodes_by_mid.items():
783
                    if node_mid_ not in map_mid_to_numbers:
784
                        map_mid_to_numbers[node_mid_] = (
785
                            len(map_mid_to_numbers) + 1
786
                        )
787
                    document_mid_number = map_mid_to_numbers[node_mid_]
788
                    assert isinstance(document_mid_number, int)
789
                    map_nodes_by_numbers[document_mid_number] = node_dict_
790
 
791
                document_map_list.append(map_nodes_by_numbers)
792
            for document_index_ in document_index_list:
793
                for term_, document_mids_ in document_index_.items():
794
                    document_mid_numbers = set()
795
                    for document_mid_ in document_mids_:
796
                        document_mid_number = map_mid_to_numbers[document_mid_]
797
                        document_mid_numbers.add(document_mid_number)
798
                    global_index[term_].update(document_mid_numbers)
799
            for map_nodes_by_mid_ in document_map_list:
800
                global_map_nodes_by_mid.update(map_nodes_by_mid_)
801
 
802
        link_renderer = LinkRenderer(
803
            root_path="",
804
            static_path=self.project_config.dir_for_sdoc_assets,
805
        )
806
        for _, node_ in global_map_nodes_by_mid.items():
807
            # When running on server, the MID is used as a link to the node.
808
            # The MID is then resolved to the correct URL by the server when
809
            # requested at /UID/{uid_or_mid}.
810
            # This ensures that all nodes can be reached with MID, including
811
            # the nodes that don't have a UID.
812
            if self.project_config.is_running_on_server:
813
                node_["_LINK"] = node_["MID"]
814
 
815
            # When running static HTML, the resolution of _LINKs happens through
816
            # the auto-generated static JS project_map.js that has a format of:
817
            # {<local anchor>: MID}
818
            else:
819
                node = traceability_index.get_node_by_mid(MID(node_["MID"]))
820
                node_["_LINK"] = link_renderer.render_local_anchor(node)
821
 
822
        def default(obj: Any) -> Any:
823
            if isinstance(obj, set):
824
                return list(obj)
825
            raise TypeError
826
 
827
        with measure_performance("Serialize search index to JS"):
828
            document_content = (
829
                b"window.StrictDoc = window.StrictDoc || {};\n"
830
                b"window.StrictDoc.search = window.StrictDoc.search || {};\n"
831
                b"window.StrictDoc.search.index = "
832
                + orjson.dumps(
833
                    global_index,
834
                    option=orjson.OPT_NON_STR_KEYS,
835
                    default=default,
836
                )
837
                + b";\n\n"
838
            )
839
 
840
        with measure_performance("Serialize lookup map {MID => node} to JS"):
841
            document_content += (
842
                b"window.StrictDoc.search.nodesByMid = "
843
                + orjson.dumps(
844
                    global_map_nodes_by_mid, option=orjson.OPT_NON_STR_KEYS
845
                )
846
                + b";\n"
847
            )
848
 
849
        # Export StrictDoc's own assets.
850
        output_html_static_files = os.path.join(
851
            self.project_config.export_output_html_root,
852
            self.project_config.dir_for_sdoc_assets,
853
        )
854
        output_html_source_coverage = os.path.join(
855
            output_html_static_files,
856
            "static_html_search_index.js",
857
        )
858
        with open(output_html_source_coverage, "wb") as file:
859
            file.write(document_content)
860
 
861
        traceability_index.search_index_timestamp = get_file_modification_time(
862
            output_html_source_coverage
863
        )
864
 
865
    def export_tree_map_screen(
866
        self,
867
        traceability_index: TraceabilityIndex,
868
    ) -> None:
869
        TreeMapGenerator.export(
870
            project_config=self.project_config,
871
            traceability_index=traceability_index,
872
            html_templates=self.html_templates,
873
        )