okstra 0.154.1 → 0.154.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/python/okstra_ctl/report_html/render.py +7 -2
- package/runtime/python/okstra_ctl/report_html/report_index.py +75 -0
- package/runtime/templates/reports/html/assets/base.css +8 -1
- package/runtime/templates/reports/html/i18n/en.json +1 -0
- package/runtime/templates/reports/html/i18n/ko.json +1 -0
- package/runtime/templates/reports/html/macros/forms.html +1 -1
package/package.json
CHANGED
package/runtime/BUILD.json
CHANGED
|
@@ -24,6 +24,7 @@ from .filters import (
|
|
|
24
24
|
paragraphs,
|
|
25
25
|
)
|
|
26
26
|
from .models import HtmlRunMeta
|
|
27
|
+
from .report_index import inject_report_index
|
|
27
28
|
from .router import HtmlRenderError, resolve_html_route
|
|
28
29
|
|
|
29
30
|
|
|
@@ -133,7 +134,8 @@ def render_v2_html_view(
|
|
|
133
134
|
# threading the index through every macro and call site.
|
|
134
135
|
anchors = anchor_index(data)
|
|
135
136
|
chrome = load_dictionary(lang, HTML_DICTIONARY_REL)
|
|
136
|
-
|
|
137
|
+
translate = make_jinja_global(chrome)
|
|
138
|
+
env.globals["t"] = translate
|
|
137
139
|
env.filters["code_evidence"] = code_evidence
|
|
138
140
|
env.filters["enum_label"] = lambda value, vocabulary: enum_label(value, vocabulary, chrome)
|
|
139
141
|
env.filters["enum_legend"] = lambda vocabulary: enum_legend(vocabulary, chrome)
|
|
@@ -154,7 +156,10 @@ def render_v2_html_view(
|
|
|
154
156
|
"js": response_js + "\n" + base_js,
|
|
155
157
|
}
|
|
156
158
|
output_path = _html_path(data_path)
|
|
157
|
-
|
|
159
|
+
document = env.get_template(route.template_name).render(**context)
|
|
160
|
+
output_path.write_text(
|
|
161
|
+
inject_report_index(document, label=translate("base.contents")), encoding="utf-8"
|
|
162
|
+
)
|
|
158
163
|
if context["clarificationItems"]:
|
|
159
164
|
# The footer tells the reader to drop the exported file here, so the
|
|
160
165
|
# directory has to exist before they go looking for it.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Build the reader's index that heads the rendered report.
|
|
2
|
+
|
|
3
|
+
The index is derived from the rendered document instead of being declared in
|
|
4
|
+
each task template. A template that gains a section gets an index entry with
|
|
5
|
+
it, and there is no second list to keep in step.
|
|
6
|
+
|
|
7
|
+
Anchors come from the id a section already carries or from its
|
|
8
|
+
``data-report-section`` slug — never from the heading text, which changes with
|
|
9
|
+
the report language and would move every link with it.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
# Every section of the human view opens with its own h2. A block that does not
|
|
16
|
+
# is not a place the reader navigates to, so it stays out of the index.
|
|
17
|
+
_SECTION_HEAD_RE = re.compile(
|
|
18
|
+
r'<section\b(?P<attrs>[^>]*)>(?P<heading_open>\s*<h2[^>]*>)(?P<title>.*?)</h2>',
|
|
19
|
+
re.DOTALL,
|
|
20
|
+
)
|
|
21
|
+
_MAIN_OPEN_RE = re.compile(r"<main\b[^>]*>")
|
|
22
|
+
_ID_RE = re.compile(r'\bid="([^"]+)"')
|
|
23
|
+
_SLUG_RE = re.compile(r'\bdata-report-section="([^"]+)"')
|
|
24
|
+
_TAG_RE = re.compile(r"<[^>]+>")
|
|
25
|
+
|
|
26
|
+
INDEX_TITLE_ID = "report-index-title"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _heading_text(title_markup: str) -> str:
|
|
30
|
+
"""The heading's words without the markup a link cannot carry."""
|
|
31
|
+
return " ".join(_TAG_RE.sub("", title_markup).split())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def inject_report_index(document: str, *, label: str) -> str:
|
|
35
|
+
"""Return ``document`` with a section index at the top of ``<main>``.
|
|
36
|
+
|
|
37
|
+
Sections that lack both an id and a slug are skipped rather than given a
|
|
38
|
+
generated anchor: a link whose target moves between renders is worse than
|
|
39
|
+
an entry the reader never had.
|
|
40
|
+
"""
|
|
41
|
+
opening = _MAIN_OPEN_RE.search(document)
|
|
42
|
+
if opening is None:
|
|
43
|
+
return document
|
|
44
|
+
head, body = document[: opening.end()], document[opening.end() :]
|
|
45
|
+
entries: list[tuple[str, str]] = []
|
|
46
|
+
|
|
47
|
+
def _anchor_section(match: re.Match[str]) -> str:
|
|
48
|
+
attrs = match.group("attrs")
|
|
49
|
+
existing = _ID_RE.search(attrs)
|
|
50
|
+
if existing:
|
|
51
|
+
anchor = existing.group(1)
|
|
52
|
+
else:
|
|
53
|
+
slug = _SLUG_RE.search(attrs)
|
|
54
|
+
if slug is None:
|
|
55
|
+
return match.group(0)
|
|
56
|
+
anchor = f"section-{slug.group(1)}"
|
|
57
|
+
# Last, not first: templates lead a section with the attribute the
|
|
58
|
+
# tests and the validator select it by.
|
|
59
|
+
attrs = f'{attrs} id="{anchor}"'
|
|
60
|
+
entries.append((anchor, _heading_text(match.group("title"))))
|
|
61
|
+
return f'<section{attrs}>{match.group("heading_open")}{match.group("title")}</h2>'
|
|
62
|
+
|
|
63
|
+
body = _SECTION_HEAD_RE.sub(_anchor_section, body)
|
|
64
|
+
if not entries:
|
|
65
|
+
return document
|
|
66
|
+
items = "".join(
|
|
67
|
+
f'<li><a href="#{anchor}">{text}</a></li>' for anchor, text in entries
|
|
68
|
+
)
|
|
69
|
+
index = (
|
|
70
|
+
f'<nav class="report-index" aria-labelledby="{INDEX_TITLE_ID}">'
|
|
71
|
+
f'<h2 id="{INDEX_TITLE_ID}">{label}</h2>'
|
|
72
|
+
f"<ol>{items}</ol>"
|
|
73
|
+
"</nav>"
|
|
74
|
+
)
|
|
75
|
+
return f"{head}\n{index}{body}"
|
|
@@ -16,6 +16,13 @@ main { display: grid; gap: 1rem; padding-bottom: 3rem; }
|
|
|
16
16
|
section { padding: 1.4rem; border: 1px solid color-mix(in srgb, CanvasText 14%, transparent); border-radius: 16px; background: color-mix(in srgb, Canvas 94%, CanvasText 6%); }
|
|
17
17
|
h2 { margin-top: 0; font-size: 1.45rem; }
|
|
18
18
|
h3 { margin-bottom: .35rem; }
|
|
19
|
+
/* The report opens with what it holds. It scrolls away with the rest rather
|
|
20
|
+
than sticking: on a phone a pinned index of a dozen sections is the page. */
|
|
21
|
+
nav.report-index { padding: 1.2rem 1.4rem; border: 1px solid color-mix(in srgb, CanvasText 14%, transparent); border-radius: 16px; background: color-mix(in srgb, Canvas 94%, CanvasText 6%); }
|
|
22
|
+
nav.report-index h2 { font-size: .82rem; text-transform: uppercase; letter-spacing: .08em; color: GrayText; margin-bottom: .6rem; }
|
|
23
|
+
nav.report-index ol { margin: 0; padding-left: 1.4rem; columns: 2; column-gap: 2.5rem; }
|
|
24
|
+
nav.report-index li { margin: .2em 0; break-inside: avoid; }
|
|
25
|
+
nav.report-index a { color: inherit; }
|
|
19
26
|
/* One card per row. Fitting three across left each body about sixteen
|
|
20
27
|
characters wide — a third of what reads comfortably — and turned every card
|
|
21
28
|
into a narrow vertical strip. These carry paragraphs, not labels. */
|
|
@@ -92,6 +99,6 @@ button:focus-visible { outline: 2px solid Highlight; outline-offset: 2px; }
|
|
|
92
99
|
button[data-action="export-user-response"] { background: Highlight; border-color: Highlight; color: HighlightText; font-weight: 600; }
|
|
93
100
|
button[data-action="export-user-response"]:hover { background: color-mix(in srgb, Highlight 82%, CanvasText); }
|
|
94
101
|
/* A seven-column table needs 42em of floor, more than a phone can give. */
|
|
95
|
-
@media (max-width: 640px) { section { padding: 1rem; } .visualization { display: none; } th, td { min-width: 4.5em; } }
|
|
102
|
+
@media (max-width: 640px) { section { padding: 1rem; } .visualization { display: none; } th, td { min-width: 4.5em; } nav.report-index ol { columns: 1; } }
|
|
96
103
|
@media print { .skip-link, script { display: none !important; } body { color: #000; background: #fff; } section { break-inside: avoid; border-color: #bbb; } .visualization-fallback { display: table; } }
|
|
97
104
|
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9em; padding: .1em .3em; border-radius: 4px; background: color-mix(in srgb, CanvasText 8%, Canvas); }
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
|
|
33
33
|
{% macro clarification_responses(items) -%}
|
|
34
34
|
{% if items %}
|
|
35
|
-
<section class="clarification-responses" aria-label="{{ t('macros.forms.questions-waiting-on-you') }}">
|
|
35
|
+
<section class="clarification-responses" data-report-section="clarification-responses" aria-label="{{ t('macros.forms.questions-waiting-on-you') }}">
|
|
36
36
|
<h2>{{ t('macros.forms.count-questions-waiting-on-you') | replace('{count}', items | length) }}</h2>
|
|
37
37
|
<p class="clarification-lede">{{ t('macros.forms.the-code-alone-could-not-settle-these-fill-i') }} <strong>{{ t('macros.forms.export-my-answers') }}</strong> {{ t('macros.forms.at-the-foot-of-the-page') }}</p>
|
|
38
38
|
{% for row in items %}
|