Skip to content

Commit 6dbfcab

Browse files
authored
Merge pull request #2159 from strictdoc-project/stanislaw/2100_export_doctree_as_js_map
html export: export project_map.js, add stable_uri_forwarder.js
2 parents dc4bb14 + 2d2ea1b commit 6dbfcab

File tree

42 files changed

+1331
-68
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+1331
-68
lines changed

strictdoc/core/document_tree_iterator.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ def __init__(self, document_tree) -> None:
1010
assert isinstance(document_tree, DocumentTree)
1111
self.document_tree: DocumentTree = document_tree
1212

13-
def is_empty_tree(self):
13+
def is_empty_tree(self) -> bool:
1414
return len(self.document_tree.document_list) == 0
1515

1616
def iterator(self):

strictdoc/export/html/_static/controllers/copy_stable_link_button_controller.js

Lines changed: 57 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -7,55 +7,76 @@
77
button.addEventListener("click", (event) => {
88
event.preventDefault();
99

10-
const clip = button.dataset.path;
10+
const link = button.dataset.path;
1111

1212
const copyIcon = this.element.querySelector(".copy_to_clipboard-copy_icon");
1313
const doneIcon = this.element.querySelector(".copy_to_clipboard-done_icon");
14-
_updateClipboard(
15-
clip,
16-
_confirm(button, copyIcon, doneIcon)
17-
)
14+
15+
// Resolve any relative URLs with respect to current URL.
16+
const resolved = (
17+
this._isAbsoluteURL(link)
18+
? link
19+
: new URL(link, window.location.href).href
20+
);
21+
22+
// Expand folder to index if we run from the local file system.
23+
const expanded = (
24+
(window.location.protocol === 'file:')
25+
? resolved.replace(/#/, 'index.html#')
26+
: resolved
27+
);
28+
29+
this._updateClipboard(expanded, this._confirmCopy(button, copyIcon, doneIcon));
1830
});
1931
}
20-
}
2132

22-
Stimulus.application.register("copy_stable_link_button", CopyStableLinkController);
33+
_isAbsoluteURL(url) {
34+
try {
35+
new URL(url); // throws if it's relative
36+
return true;
37+
} catch {
38+
return false;
39+
}
40+
}
2341

24-
function _updateClipboard(newClip, callback) {
25-
navigator.clipboard.writeText(newClip).then(() => {
26-
/* clipboard successfully set */
27-
() => callback();
28-
console.info('clipboard successfully set: ', newClip);
29-
}, () => {
30-
/* clipboard write failed */
31-
console.warn('clipboard write failed');
32-
});
33-
}
42+
_updateClipboard(newClip, callback) {
43+
navigator.clipboard.writeText(newClip).then(() => {
44+
/* clipboard successfully set */
45+
() => callback();
46+
console.info('Clipboard successfully set: ', newClip);
47+
}, () => {
48+
/* clipboard write failed */
49+
console.warn('Clipboard write failed');
50+
});
51+
}
3452

35-
function _confirm(button, copyIcon, doneIcon) {
36-
// initial opacity
37-
let op = 1;
53+
_confirmCopy(button, copyIcon, doneIcon) {
54+
// initial opacity
55+
let op = 1;
3856

39-
// make button visible
40-
button.style.opacity = 1;
57+
// make button visible
58+
button.style.opacity = 1;
4159

42-
// make DONE icon visible (instead of default COPY)
43-
copyIcon.style.display = 'none';
44-
doneIcon.style.display = 'contents';
60+
// make DONE icon visible (instead of default COPY)
61+
copyIcon.style.display = 'none';
62+
doneIcon.style.display = 'contents';
4563

46-
const fadeTimer = setInterval(() => {
47-
if (op <= 0.1) {
48-
clearInterval(fadeTimer);
64+
const fadeTimer = setInterval(() => {
65+
if (op <= 0.1) {
66+
clearInterval(fadeTimer);
4967

50-
// make button invisible
51-
button.style.opacity = '';
68+
// make button invisible
69+
button.style.opacity = '';
5270

53-
// make COPY icon visible back
54-
copyIcon.style.display = 'contents';
55-
doneIcon.style.display = 'none';
56-
}
57-
op -= op * 0.1;
58-
}, 30);
71+
// make COPY icon visible back
72+
copyIcon.style.display = 'contents';
73+
doneIcon.style.display = 'none';
74+
}
75+
op -= op * 0.1;
76+
}, 30);
77+
}
5978
}
6079

80+
Stimulus.application.register("copy_stable_link_button", CopyStableLinkController);
81+
6182
})();
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// stable_uri_forwarder.js
2+
//
3+
// This script is included from the project's index.html page (in static HTML export)
4+
// It checks for a known MID or UID in the #anchor, and then
5+
// redirects to the referenced section or node within the project.
6+
//
7+
// Example:
8+
// http://strictdoc.company.com/#SDOC_UG will redirect to
9+
// http://strictdoc.company.com/strictdoc/docs/strictdoc_01_user_guide.html#SDOC_UG
10+
//
11+
// Thanks to this mechanism, it becomes possible to export stable links to
12+
// nodes/requirements/sections for integration with external tools.
13+
// The external links remain stable, even if the node/requirement/section is moved
14+
// within the project.
15+
16+
17+
// Resolve the MID / UID to the correct page / anchor using the projectMap.
18+
function resolveStableUriRedirectUsingProjectMap(anchor) {
19+
const anchorIsMID = /^[a-fA-F0-9]{32}$/.test(anchor);
20+
for (const [page, nodes] of Object.entries(projectMap)) {
21+
for (const node of nodes) {
22+
const nodeHasMID = 'MID' in node && typeof node['MID'] === 'string';
23+
if ( (anchorIsMID && nodeHasMID && node['MID']?.toLowerCase() === anchor.toLowerCase())
24+
|| (node['UID'] === anchor)
25+
)
26+
{
27+
window.location.replace(page + "#" + node['UID']);
28+
return;
29+
}
30+
}
31+
}
32+
}
33+
34+
// Dynamically load the projectMap an resolve MID / UID.
35+
function loadProjectMapAndResolveStableUriRedirect(anchor) {
36+
37+
// ProjectMap is loaded, no need to load it again.
38+
if (typeof projectMap !== 'undefined') {
39+
resolveStableUriRedirectUsingProjectMap(anchor);
40+
return;
41+
}
42+
43+
// Get script URL and derive from it the url of project_map.js
44+
const scriptUrl = new URL(document.getElementById("stable_uri_forwarder").src, window.location.href)
45+
const projectMapUrl = new URL('project_map.js', scriptUrl).href;
46+
47+
// Dynamically load project map and resolve
48+
const script = document.createElement("script");
49+
script.src = projectMapUrl;
50+
script.onload = () => resolveStableUriRedirectUsingProjectMap(anchor);
51+
script.onerror = () => {
52+
console.error(`Failed to load project map from ${projectMapUrl}`);
53+
};
54+
document.head.appendChild(script);
55+
}
56+
57+
function processStableUriRedirect(anchor)
58+
{
59+
const exportType = document.querySelector('meta[name="strictdoc-export-type"]')?.content;
60+
61+
if (exportType === 'webserver') {
62+
// For the web server, we let the main_router.py dynamically forward UID to node.
63+
window.location.replace("/UID/" + anchor);
64+
} else if (exportType === 'static') {
65+
// For static exports, we use the project_map.js.
66+
loadProjectMapAndResolveStableUriRedirect(anchor)
67+
}
68+
}
69+
70+
71+
// In case an anchor is present at content load time.
72+
document.addEventListener("DOMContentLoaded", () => {
73+
const anchor = window.location.hash.substring(1);
74+
if (anchor) {
75+
processStableUriRedirect(anchor)
76+
}
77+
});
78+
79+
// In case an anchor is added manually afterwards (eases testing).
80+
window.addEventListener("hashchange", () => {
81+
const anchor = window.location.hash.substring(1);
82+
if (anchor) {
83+
processStableUriRedirect(anchor)
84+
}
85+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from markupsafe import Markup
2+
3+
from strictdoc.core.project_config import ProjectConfig
4+
from strictdoc.core.traceability_index import TraceabilityIndex
5+
from strictdoc.export.html.generators.view_objects.project_tree_view_object import (
6+
ProjectTreeViewObject,
7+
)
8+
from strictdoc.export.html.html_templates import HTMLTemplates
9+
10+
11+
class ProjectMapGenerator:
12+
@staticmethod
13+
def export(
14+
project_config: ProjectConfig,
15+
traceability_index: TraceabilityIndex,
16+
html_templates: HTMLTemplates,
17+
) -> Markup:
18+
assert isinstance(html_templates, HTMLTemplates)
19+
20+
view_object = ProjectTreeViewObject(
21+
traceability_index=traceability_index,
22+
project_config=project_config,
23+
)
24+
return view_object.render_map(html_templates.jinja_environment())

strictdoc/export/html/generators/view_objects/document_screen_view_object.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -349,10 +349,24 @@ def should_display_included_documents_for_document(
349349

350350
def should_display_stable_link(
351351
self, node: Union[SDocDocument, SDocSection, SDocNode]
352-
):
352+
) -> bool:
353353
assert isinstance(node, (SDocDocument, SDocSection, SDocNode)), node
354354
return node.reserved_uid is not None
355355

356-
def get_stable_link(self, node: Union[SDocDocument, SDocSection, SDocNode]):
356+
def get_stable_link(
357+
self, node: Union[SDocDocument, SDocSection, SDocNode]
358+
) -> str:
359+
"""
360+
An example of a link produced: ../../#SDOC_UG_CONTACT
361+
The copy_stable_link_button_controller.js consumes this link and
362+
transforms it into a link like:
363+
http://127.0.0.1:5111/#SDOC_UG_CONTACT
364+
"""
365+
357366
assert isinstance(node, (SDocDocument, SDocSection, SDocNode)), node
358-
return "#TBD"
367+
base_url = self.link_renderer.render_url("")
368+
if node.reserved_uid is not None:
369+
return base_url + "#" + node.reserved_uid
370+
if node.reserved_mid is not None and node.mid_permanent:
371+
return base_url + "#" + node.reserved_mid
372+
return base_url

strictdoc/export/html/generators/view_objects/project_tree_view_object.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
# mypy: disable-error-code="no-any-return,no-untyped-call,no-untyped-def"
21
from dataclasses import dataclass
32

3+
from markupsafe import Markup
4+
45
from strictdoc import __version__
56
from strictdoc.core.document_tree_iterator import DocumentTreeIterator
67
from strictdoc.core.file_tree import File, Folder
@@ -40,18 +41,23 @@ def __init__(
4041
traceability_index.contains_included_documents
4142
)
4243

43-
def render_screen(self, jinja_environment: JinjaEnvironment):
44+
def render_screen(self, jinja_environment: JinjaEnvironment) -> Markup:
4445
return jinja_environment.render_template_as_markup(
4546
"screens/project_index/index.jinja", view_object=self
4647
)
4748

48-
def render_static_url(self, url: str):
49+
def render_map(self, jinja_environment: JinjaEnvironment) -> Markup:
50+
return jinja_environment.render_template_as_markup(
51+
"screens/project_index/project_map.jinja.js", view_object=self
52+
)
53+
54+
def render_static_url(self, url: str) -> str:
4955
return self.link_renderer.render_static_url(url)
5056

51-
def render_url(self, url: str):
57+
def render_url(self, url: str) -> str:
5258
return self.link_renderer.render_url(url)
5359

54-
def render_static_url_with_prefix(self, url: str):
60+
def render_static_url_with_prefix(self, url: str) -> str:
5561
return self.link_renderer.render_static_url_with_prefix(url)
5662

5763
def is_empty_tree(self) -> bool:

strictdoc/export/html/html_generator.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@
2929
from strictdoc.export.html.generators.document_tree import (
3030
DocumentTreeHTMLGenerator,
3131
)
32+
from strictdoc.export.html.generators.project_map import (
33+
ProjectMapGenerator,
34+
)
3235
from strictdoc.export.html.generators.project_statistics import (
3336
ProgressStatisticsGenerator,
3437
)
@@ -106,6 +109,9 @@ def export_complete_tree(
106109
# _pickle.PicklingError: Can't pickle <function sync_do_first at 0x1077bdf80>: it's not the same object as jinja2.filters.sync_do_first
107110
self.export_project_tree_screen(traceability_index=traceability_index)
108111

112+
# Export JavaScript map of the document tree (project map)
113+
self.export_project_map(traceability_index=traceability_index)
114+
109115
# Export project statistics.
110116
if self.project_config.is_feature_activated(
111117
ProjectFeature.PROJECT_STATISTICS_SCREEN
@@ -487,6 +493,25 @@ def export_project_tree_screen(
487493
with open(output_file, "w", encoding="utf8") as file:
488494
file.write(output)
489495

496+
def export_project_map(
497+
self,
498+
*,
499+
traceability_index: TraceabilityIndex,
500+
):
501+
assets_dir = os.path.join(
502+
self.project_config.export_output_html_root,
503+
self.project_config.dir_for_sdoc_assets,
504+
)
505+
output_file = os.path.join(assets_dir, "project_map.js")
506+
writer = ProjectMapGenerator()
507+
output = writer.export(
508+
self.project_config,
509+
traceability_index=traceability_index,
510+
html_templates=self.html_templates,
511+
)
512+
with open(output_file, "w", encoding="utf8") as file:
513+
file.write(output)
514+
490515
def export_requirements_coverage_screen(
491516
self,
492517
*,

strictdoc/export/html/renderers/link_renderer.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ def __init__(self, *, root_path, static_path: str):
2727
self.local_anchor_cache = {}
2828
self.req_link_cache = {}
2929

30-
def render_url(self, url):
30+
def render_url(self, url: str) -> str:
3131
url = html.escape(url)
3232
if len(self.root_path) == 0:
3333
return url
@@ -39,7 +39,7 @@ def render_static_url(self, url: str) -> str:
3939
# This rarely used helper adds slashes to the import statements within
4040
# <script type="module">, for example project_index/index.jinja.
4141
# Otherwise, scripts are not imported correctly.
42-
def render_static_url_with_prefix(self, url):
42+
def render_static_url_with_prefix(self, url: str) -> str:
4343
static_url = "/" + self.static_path + "/" + url
4444
return static_url
4545

strictdoc/export/html/templates/base.jinja.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55
<meta charset="UTF-8"/>
66
<meta name="keywords" content="strictdoc, documentation, documentation-tool, requirements-management, requirements, documentation-generator, requirement-specifications, requirements-engineering, technical-documentation, requirements-specification"/>
77
<meta name="description" content="strictdoc. Software for technical documentation and requirements management."/>
8+
{%- if view_object.project_config.is_running_on_server %}
9+
<meta name="strictdoc-export-type" content="webserver">
10+
{%- else -%}
11+
<meta name="strictdoc-export-type" content="static">
12+
{%- endif -%}
813

914
<link rel="shortcut icon" href="{{ view_object.render_static_url('favicon.ico') }}" type="image/x-icon"/>
1015

strictdoc/export/html/templates/components/node/copy_stable_link_button.jinja

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,12 @@
11
{#
2-
Requires: copy_stable_link_observer.js (uses MutationObserver).
3-
4-
To return to using Stimulus:
5-
use copy_stable_link_button_controller.js in index.jinja
6-
and uncomment [data-controller] below.
2+
Requires: copy_stable_link_button_controller.js in index.jinja
73
#}
84
{%- if path is defined -%}
95
<div
106
data-controller="copy_stable_link_button"
117
data-path="{{ path }}"
128
class="copy_stable_link-button"
13-
title="Click to copy stable link to the node"
9+
title="Click to copy a stable node link to the clipboard"
1410
>
1511
<span style="display: contents;" class="copy_to_clipboard-copy_icon">
1612
{% include "_res/svg_ico16_link.jinja" %}

0 commit comments

Comments
 (0)