StrictDoc Documentation
strictdoc/export/html/html_generator.py
Source file coverage
Path:
strictdoc/export/html/html_generator.py
Lines:
795
Non-empty lines:
712
Non-empty lines covered with requirements:
712 / 712 (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
        # Copy the project's custom CSS file, if configured. base.jinja.html
253
        # links it after StrictDoc's own stylesheets so it can override them.
254
        custom_css_path = project_config.custom_css_path
255
        if custom_css_path is not None:
256
            shutil.copyfile(
257
                custom_css_path,
258
                os.path.join(
259
                    output_html_static_files,
260
                    project_config.get_custom_css_filename(),
261
                ),
262
            )
263
 
264
        # Export HTML2PDF.
265
        if project_config.is_feature_activated(ProjectFeature.HTML2PDF):
266
            sync_dir(
267
                os.path.dirname(PATH_TO_HTML2PDF4DOC_JS),
268
                output_html_static_files,
269
                message="Copying HTML2PDF.js",
270
            )
271
 
272
        # Export custom html2pdf template.
273
        if project_config.html2pdf_template is not None:
274
            output_custom_html2pdf_template = os.path.join(
275
                export_output_html_root,
276
                project_config.dir_for_sdoc_assets,
277
                "html2pdf_template",
278
            )
279
            sync_dir(
280
                os.path.abspath(
281
                    os.path.dirname(project_config.html2pdf_template)
282
                ),
283
                output_custom_html2pdf_template,
284
                message="Copying Custom HTML2PDF template assets",
285
            )
286
 
287
        # Export project's assets.
288
 
289
        if traceability_index is not None:
290
            redundant_assets: Dict[str, List[SDocRelativePath]] = {}
291
            for document_ in traceability_index.document_tree.document_list:
292
                assert document_.meta is not None
293
                for (
294
                    included_document_
295
                ) in document_.iterate_included_documents_depth_first():
296
                    assert included_document_.meta is not None
297
 
298
                    redundant_assets.setdefault(
299
                        document_.meta.input_doc_assets_dir_rel_path.relative_path_posix,
300
                        [],
301
                    )
302
                    redundant_assets[
303
                        document_.meta.input_doc_assets_dir_rel_path.relative_path_posix
304
                    ].append(
305
                        included_document_.meta.input_doc_assets_dir_rel_path
306
                    )
307
 
308
            assert traceability_index.asset_manager is not None
309
 
310
            asset_dir_: AssetDir
311
            for asset_dir_ in traceability_index.asset_manager.iterate():
312
                source_path = asset_dir_.full_path
313
                output_relative_path = asset_dir_.relative_path
314
 
315
                destination_path = os.path.join(
316
                    export_output_html_root,
317
                    output_relative_path.relative_path
318
                    if not flat_assets
319
                    else "_assets",
320
                )
321
 
322
                sync_dir(
323
                    source_path,
324
                    destination_path,
325
                    message=f'Copying project assets "{output_relative_path.relative_path}"',
326
                )
327
                redundant_asset_paths = redundant_assets.get(
328
                    output_relative_path.relative_path_posix
329
                )
330
                if redundant_asset_paths is not None:
331
                    for redundant_asset_ in redundant_asset_paths:
332
                        destination_path = os.path.join(
333
                            export_output_html_root,
334
                            redundant_asset_.relative_path
335
                            if not flat_assets
336
                            else "_assets",
337
                        )
338
                        sync_dir(
339
                            source_path,
340
                            destination_path,
341
                            message=f'Copying project assets "{output_relative_path.relative_path}"',
342
                        )
343
 
344
    def export_single_document_with_performance(
345
        self,
346
        document: SDocDocument,
347
        traceability_index: TraceabilityIndex,
348
        specific_documents: Optional[Tuple[DocumentType, ...]] = None,
349
    ) -> None:
350
        if specific_documents is None:
351
            specific_documents = DocumentType.all()
352
 
353
        with measure_performance(f"Published: {document.title}"):
354
            self.export_single_document(
355
                document,
356
                traceability_index,
357
                specific_documents=specific_documents,
358
            )
359
 
360
    def export_single_document(
361
        self,
362
        document: SDocDocument,
363
        traceability_index: TraceabilityIndex,
364
        specific_documents: Optional[Tuple[DocumentType, ...]] = None,
365
    ) -> SDocDocument:
366
        if document.config.layout == "Website":
367
            specific_documents = (DocumentType.DOCUMENT,)
368
        elif specific_documents is None:
369
            specific_documents = DocumentType.all()
370
 
371
        assert document.meta is not None
372
 
373
        document_meta: DocumentMeta = document.meta
374
 
375
        document_output_folder = document_meta.output_document_dir_full_path
376
        Path(document_output_folder).mkdir(parents=True, exist_ok=True)
377
 
378
        root_path = document.meta.get_root_path_prefix()
379
        link_renderer = LinkRenderer(
380
            root_path=root_path,
381
            static_path=self.project_config.dir_for_sdoc_assets,
382
        )
383
        markup_renderer = MarkupRenderer.create(
384
            markup=document.config.markup,
385
            traceability_index=traceability_index,
386
            link_renderer=link_renderer,
387
            html_templates=self.html_templates,
388
            config=self.project_config,
389
            context_document=document,
390
        )
391
 
392
        if DocumentType.DOCUMENT in specific_documents:
393
            # Single Document pages.
394
            document_content = DocumentHTMLGenerator.export(
395
                project_config=self.project_config,
396
                document=document,
397
                traceability_index=traceability_index,
398
                markup_renderer=markup_renderer,
399
                link_renderer=link_renderer,
400
                git_client=self.git_client,
401
                html_templates=self.html_templates,
402
            )
403
            document_out_file = document_meta.get_html_doc_path()
404
            with open(document_out_file, "w", encoding="utf8") as file:
405
                file.write(document_content)
406
 
407
        # Single Document Table pages.
408
        if (
409
            self.project_config.is_feature_activated(
410
                ProjectFeature.TABLE_SCREEN
411
            )
412
            and DocumentType.TABLE in specific_documents
413
        ):
414
            document_content = DocumentTableHTMLGenerator.export(
415
                project_config=self.project_config,
416
                document=document,
417
                traceability_index=traceability_index,
418
                markup_renderer=markup_renderer,
419
                link_renderer=link_renderer,
420
                git_client=self.git_client,
421
                html_templates=self.html_templates,
422
            )
423
            document_out_file = document_meta.get_html_table_path()
424
            with open(document_out_file, "w", encoding="utf8") as file:
425
                file.write(document_content)
426
 
427
        # Single Document Traceability pages.
428
        if (
429
            self.project_config.is_feature_activated(
430
                ProjectFeature.TRACEABILITY_SCREEN
431
            )
432
            and DocumentType.TRACE in specific_documents
433
        ):
434
            document_content = DocumentTraceHTMLGenerator.export(
435
                project_config=self.project_config,
436
                document=document,
437
                traceability_index=traceability_index,
438
                markup_renderer=markup_renderer,
439
                link_renderer=link_renderer,
440
                git_client=self.git_client,
441
                html_templates=self.html_templates,
442
            )
443
            document_out_file = document_meta.get_html_traceability_path()
444
            with open(document_out_file, "w", encoding="utf8") as file:
445
                file.write(document_content)
446
 
447
        # Single Document Deep Traceability pages.
448
        if (
449
            self.project_config.is_feature_activated(
450
                ProjectFeature.DEEP_TRACEABILITY_SCREEN
451
            )
452
            and DocumentType.DEEPTRACE in specific_documents
453
        ):
454
            document_content = DocumentDeepTraceHTMLGenerator.export_deep(
455
                project_config=self.project_config,
456
                document=document,
457
                traceability_index=traceability_index,
458
                markup_renderer=markup_renderer,
459
                link_renderer=link_renderer,
460
                git_client=self.git_client,
461
                html_templates=self.html_templates,
462
            )
463
            document_out_file = document_meta.get_html_deep_traceability_path()
464
            with open(document_out_file, "w", encoding="utf8") as file:
465
                file.write(document_content)
466
 
467
        # Single Document PDF pages.
468
        if (
469
            self.project_config.is_feature_activated(ProjectFeature.HTML2PDF)
470
            and DocumentType.PDF in specific_documents
471
        ):
472
            document_content = DocumentHTML2PDFGenerator.export(
473
                project_config=self.project_config,
474
                document=document,
475
                traceability_index=traceability_index,
476
                markup_renderer=markup_renderer,
477
                link_renderer=link_renderer,
478
                git_client=self.git_client,
479
                html_templates=self.html_templates,
480
            )
481
            document_out_file = document_meta.get_html_pdf_path()
482
            with open(document_out_file, "w", encoding="utf8") as file:
483
                file.write(document_content)
484
 
485
        return document
486
 
487
    def export_project_tree_screen(
488
        self,
489
        *,
490
        traceability_index: TraceabilityIndex,
491
    ) -> None:
492
        Path(self.project_config.export_output_html_root).mkdir(
493
            parents=True, exist_ok=True
494
        )
495
        output_file = os.path.join(
496
            self.project_config.export_output_html_root, "index.html"
497
        )
498
        writer = DocumentTreeHTMLGenerator()
499
        output = writer.export(
500
            self.project_config,
501
            traceability_index=traceability_index,
502
            html_templates=self.html_templates,
503
        )
504
        with open(output_file, "w", encoding="utf8") as file:
505
            file.write(output)
506
 
507
    def export_project_map(
508
        self,
509
        *,
510
        traceability_index: TraceabilityIndex,
511
    ) -> None:
512
        assets_dir = os.path.join(
513
            self.project_config.export_output_html_root,
514
            self.project_config.dir_for_sdoc_assets,
515
        )
516
        output_file = os.path.join(assets_dir, "project_map.js")
517
        writer = ProjectMapGenerator()
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_requirements_coverage_screen(
527
        self,
528
        *,
529
        traceability_index: TraceabilityIndex,
530
    ) -> None:
531
        requirements_coverage_content = TraceabilityMatrixHTMLGenerator.export(
532
            project_config=self.project_config,
533
            traceability_index=traceability_index,
534
            html_templates=self.html_templates,
535
        )
536
        output_html_requirements_coverage = os.path.join(
537
            self.project_config.export_output_html_root,
538
            "traceability_matrix.html",
539
        )
540
        with open(
541
            output_html_requirements_coverage, "w", encoding="utf8"
542
        ) as file:
543
            file.write(requirements_coverage_content)
544
 
545
    @timing_decorator("Export source file pages")
546
    def export_source_files_screens(
547
        self,
548
        *,
549
        traceability_index: TraceabilityIndex,
550
    ) -> None:
551
        assert isinstance(
552
            traceability_index.document_tree.source_tree, SourceTree
553
        ), traceability_index.document_tree.source_tree
554
        print("Generating source files:")  # noqa: T201
555
        for (
556
            source_file
557
        ) in traceability_index.document_tree.source_tree.source_files:
558
            if not source_file.is_referenced:
559
                continue
560
 
561
            SourceFileViewHTMLGenerator.export_to_file(
562
                project_config=self.project_config,
563
                source_file=source_file,
564
                traceability_index=traceability_index,
565
                html_templates=self.html_templates,
566
            )
567
 
568
    def export_source_coverage_screen(
569
        self,
570
        *,
571
        traceability_index: TraceabilityIndex,
572
    ) -> None:
573
        assert isinstance(
574
            traceability_index.document_tree.source_tree, SourceTree
575
        ), traceability_index.document_tree.source_tree
576
 
577
        source_coverage_content = SourceFileCoverageHTMLGenerator.export(
578
            project_config=self.project_config,
579
            traceability_index=traceability_index,
580
            html_templates=self.html_templates,
581
        )
582
        output_html_source_coverage = os.path.join(
583
            self.project_config.export_output_html_root, "source_coverage.html"
584
        )
585
        with open(output_html_source_coverage, "w", encoding="utf8") as file:
586
            file.write(source_coverage_content)
587
 
588
    def export_single_source_file_screen(
589
        self,
590
        *,
591
        traceability_index: TraceabilityIndex,
592
        path_to_source_file: str,
593
    ) -> None:
594
        assert isinstance(
595
            traceability_index.document_tree.source_tree, SourceTree
596
        ), traceability_index.document_tree.source_tree
597
 
598
        # FIXME: path_to_source_file must not enter this function with forward slashes.
599
        #        Test and fix this on Windows.
600
        #        https://github.com/strictdoc-project/strictdoc/issues/2068
601
        relative_path_to_source_file = path_to_posix_path(path_to_source_file)
602
        relative_path_to_source_file = (
603
            relative_path_to_source_file.removeprefix("_source_files/")
604
        )
605
        relative_path_to_source_file = (
606
            relative_path_to_source_file.removesuffix(".html")
607
        )
608
 
609
        for (
610
            source_file
611
        ) in traceability_index.document_tree.source_tree.source_files:
612
            if not source_file.is_referenced:
613
                continue
614
 
615
            if (
616
                relative_path_to_source_file
617
                == source_file.in_doctree_source_file_rel_path_posix
618
            ):
619
                SourceFileViewHTMLGenerator.export_to_file(
620
                    project_config=self.project_config,
621
                    source_file=source_file,
622
                    traceability_index=traceability_index,
623
                    html_templates=self.html_templates,
624
                )
625
                return
626
 
627
        raise FileNotFoundError
628
 
629
    @timing_decorator("Export static HTML search index")
630
    def export_static_html_search_index(
631
        self,
632
        traceability_index: TraceabilityIndex,
633
        *,
634
        force_regeneration: bool = False,
635
    ) -> None:
636
        """
637
        Export a static search index as dictionaries in .js files.
638
 
639
        @relation(SDOC-SRS-155, scope=function)
640
        @relation(SDOC-SRS-156, scope=function)
641
        """
642
 
643
        if not force_regeneration:
644
            # First check if there is nothing to do because no documents have
645
            # been changed or regenerated.
646
 
647
            # FIXME: This is wrong. FIX!
648
            must_regenerate = (
649
                len(traceability_index.document_tree.document_list) == 0
650
            )
651
 
652
            for document_ in traceability_index.document_tree.document_list:
653
                assert document_.meta is not None
654
                if traceability_index.file_dependency_manager.must_generate(
655
                    document_.meta.output_document_full_path
656
                ):
657
                    must_regenerate = True
658
                    break
659
 
660
            if not must_regenerate:
661
                print(  # noqa: T201
662
                    "All documents are up-to-date. "
663
                    "Skipping the generation of a search index."
664
                )
665
                # If no documents need to be regenerated, set the
666
                # search_index_timestamp to the timestamp of the first document.
667
                # The HTML/JS code can rely on this timestamp to decide whether
668
                # it has to re-read the search index from the JS file or it can
669
                # fetch it from the DB.
670
                if len(traceability_index.document_tree.document_list) > 0:
671
                    first_document = (
672
                        traceability_index.document_tree.document_list[0]
673
                    )
674
                    assert first_document.meta is not None
675
                    traceability_index.search_index_timestamp = (
676
                        get_file_modification_time(
677
                            first_document.meta.input_doc_full_path
678
                        )
679
                    )
680
                return
681
 
682
        if force_regeneration:
683
            for document_ in traceability_index.document_tree.document_list:
684
                document_.build_search_index()
685
 
686
        global_index: Dict[str, Set[int]] = defaultdict(set)
687
        global_map_nodes_by_mid: Dict[int, Dict[str, str]] = {}
688
 
689
        document_index_list: List[Dict[str, Set[str]]] = []
690
        document_map_list: List[Dict[int, Dict[str, str]]] = []
691
 
692
        map_mid_to_numbers: Dict[str, int] = {}
693
 
694
        with measure_performance("Build search index"):
695
            for document_ in traceability_index.document_tree.document_list:
696
                assert document_.meta is not None
697
                document_index_list.append(
698
                    document_.search_index.document_index
699
                )
700
                map_nodes_by_numbers: Dict[int, Dict[str, str]] = {}
701
                for (
702
                    node_mid_,
703
                    node_dict_,
704
                ) in document_.search_index.map_nodes_by_mid.items():
705
                    if node_mid_ not in map_mid_to_numbers:
706
                        map_mid_to_numbers[node_mid_] = (
707
                            len(map_mid_to_numbers) + 1
708
                        )
709
                    document_mid_number = map_mid_to_numbers[node_mid_]
710
                    assert isinstance(document_mid_number, int)
711
                    map_nodes_by_numbers[document_mid_number] = node_dict_
712
 
713
                document_map_list.append(map_nodes_by_numbers)
714
            for document_index_ in document_index_list:
715
                for term_, document_mids_ in document_index_.items():
716
                    document_mid_numbers = set()
717
                    for document_mid_ in document_mids_:
718
                        document_mid_number = map_mid_to_numbers[document_mid_]
719
                        document_mid_numbers.add(document_mid_number)
720
                    global_index[term_].update(document_mid_numbers)
721
            for map_nodes_by_mid_ in document_map_list:
722
                global_map_nodes_by_mid.update(map_nodes_by_mid_)
723
 
724
        link_renderer = LinkRenderer(
725
            root_path="",
726
            static_path=self.project_config.dir_for_sdoc_assets,
727
        )
728
        for _, node_ in global_map_nodes_by_mid.items():
729
            # When running on server, the MID is used as a link to the node.
730
            # The MID is then resolved to the correct URL by the server when
731
            # requested at /UID/{uid_or_mid}.
732
            # This ensures that all nodes can be reached with MID, including
733
            # the nodes that don't have a UID.
734
            if self.project_config.is_running_on_server:
735
                node_["_LINK"] = node_["MID"]
736
 
737
            # When running static HTML, the resolution of _LINKs happens through
738
            # the auto-generated static JS project_map.js that has a format of:
739
            # {<local anchor>: MID}
740
            else:
741
                node = traceability_index.get_node_by_mid(MID(node_["MID"]))
742
                node_["_LINK"] = link_renderer.render_local_anchor(node)
743
 
744
        def default(obj: Any) -> Any:
745
            if isinstance(obj, set):
746
                return list(obj)
747
            raise TypeError
748
 
749
        with measure_performance("Serialize search index to JS"):
750
            document_content = (
751
                b"window.StrictDoc = window.StrictDoc || {};\n"
752
                b"window.StrictDoc.search = window.StrictDoc.search || {};\n"
753
                b"window.StrictDoc.search.index = "
754
                + orjson.dumps(
755
                    global_index,
756
                    option=orjson.OPT_NON_STR_KEYS,
757
                    default=default,
758
                )
759
                + b";\n\n"
760
            )
761
 
762
        with measure_performance("Serialize lookup map {MID => node} to JS"):
763
            document_content += (
764
                b"window.StrictDoc.search.nodesByMid = "
765
                + orjson.dumps(
766
                    global_map_nodes_by_mid, option=orjson.OPT_NON_STR_KEYS
767
                )
768
                + b";\n"
769
            )
770
 
771
        # Export StrictDoc's own assets.
772
        output_html_static_files = os.path.join(
773
            self.project_config.export_output_html_root,
774
            self.project_config.dir_for_sdoc_assets,
775
        )
776
        output_html_source_coverage = os.path.join(
777
            output_html_static_files,
778
            "static_html_search_index.js",
779
        )
780
        with open(output_html_source_coverage, "wb") as file:
781
            file.write(document_content)
782
 
783
        traceability_index.search_index_timestamp = get_file_modification_time(
784
            output_html_source_coverage
785
        )
786
 
787
    def export_tree_map_screen(
788
        self,
789
        traceability_index: TraceabilityIndex,
790
    ) -> None:
791
        TreeMapGenerator.export(
792
            project_config=self.project_config,
793
            traceability_index=traceability_index,
794
            html_templates=self.html_templates,
795
        )