@cosmicdrift/kumiko-cli 0.356.0 → 0.357.0
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 +20 -9
- package/src/changes.json +6 -0
- package/src/commands/handbuch.ts +123 -0
- package/src/commands/index.ts +3 -1
- package/src/handbuch/build.ts +102 -0
- package/src/handbuch/buildinfo.ts +112 -0
- package/src/handbuch/chrome.ts +96 -0
- package/src/handbuch/config.ts +97 -0
- package/src/handbuch/flows.ts +96 -0
- package/src/handbuch/model.ts +574 -0
- package/src/handbuch/page-assets.ts +209 -0
- package/src/handbuch/render.ts +396 -0
- package/src/handbuch/uistrings.ts +280 -0
- package/src/missing-peer.ts +1 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
export const PAGE_STYLES = `
|
|
2
|
+
:root{
|
|
3
|
+
--bg:#f3f4f1;--panel:#fff;--ink:#14161a;--muted:#5f645d;--line:#dfe2dc;
|
|
4
|
+
--accent:#2b5cff;--accent-ink:#fff;--warn:#b4541a;--warn-bg:#fbeee4;
|
|
5
|
+
--frame:#1b1d22;--stage:#e8eae4;--code-bg:#eef0eb;
|
|
6
|
+
--font-sans:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
|
|
7
|
+
--font-mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
|
8
|
+
color-scheme:light dark;
|
|
9
|
+
}
|
|
10
|
+
@media (prefers-color-scheme:dark){:root{
|
|
11
|
+
--bg:#101216;--panel:#171a1f;--ink:#eceee8;--muted:#9aa096;--line:#2a2e35;
|
|
12
|
+
--accent:#5b82ff;--accent-ink:#0c0e12;--warn:#f0a36a;--warn-bg:#2e2119;
|
|
13
|
+
--frame:#050608;--stage:#0c0e11;--code-bg:#22262c}}
|
|
14
|
+
*{box-sizing:border-box}
|
|
15
|
+
html,body{margin:0;min-height:100%}
|
|
16
|
+
body{background:var(--bg);color:var(--ink);font-family:var(--font-sans);font-size:15px;line-height:1.6;overflow-x:hidden}
|
|
17
|
+
[hidden]{display:none!important}
|
|
18
|
+
a{color:var(--accent)}
|
|
19
|
+
code{font-family:var(--font-mono);font-size:.88em;background:var(--code-bg);padding:.1em .35em;border-radius:4px;overflow-wrap:anywhere}
|
|
20
|
+
img{max-width:100%;height:auto}
|
|
21
|
+
.app{display:grid;grid-template-columns:280px minmax(0,1fr);min-height:100vh}
|
|
22
|
+
aside{background:var(--panel);border-right:1px solid var(--line);display:flex;flex-direction:column;position:sticky;top:0;height:100vh;min-height:0}
|
|
23
|
+
.brand{padding:20px 20px 14px;border-bottom:1px solid var(--line)}
|
|
24
|
+
.brand h1{font-size:19px;margin:0;line-height:1.25}
|
|
25
|
+
.build-info{margin:6px 0 0;font-family:var(--font-mono);font-size:11px;color:var(--muted);line-height:1.5}
|
|
26
|
+
.stats{font-family:var(--font-mono);font-size:11px;color:var(--muted);margin-top:10px;display:flex;gap:4px 12px;flex-wrap:wrap}
|
|
27
|
+
.stats b{color:var(--ink);font-weight:600}
|
|
28
|
+
nav{overflow:auto;padding:8px 0 24px;flex:1}
|
|
29
|
+
nav h3{font-family:var(--font-mono);font-weight:500;font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin:16px 20px 6px}
|
|
30
|
+
nav a{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:6px 20px;color:var(--ink);text-decoration:none;font-size:13.5px;line-height:1.3}
|
|
31
|
+
nav a:hover{background:var(--stage)}
|
|
32
|
+
nav a:focus-visible,.seg button:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
|
|
33
|
+
nav a[aria-current="true"]{background:var(--accent);color:var(--accent-ink)}
|
|
34
|
+
.tag{font-family:var(--font-mono);font-size:10px;padding:1px 6px;border-radius:99px;white-space:nowrap;margin-left:4px}
|
|
35
|
+
.tag.warn{background:var(--warn-bg);color:var(--warn)}
|
|
36
|
+
nav a[aria-current="true"] .tag{background:var(--accent-ink);color:var(--accent)}
|
|
37
|
+
main{min-width:0}
|
|
38
|
+
.bar{display:flex;flex-wrap:wrap;align-items:center;gap:10px 20px;padding:12px 24px;border-bottom:1px solid var(--line);background:var(--panel);position:sticky;top:0;z-index:2}
|
|
39
|
+
.bar .title{flex:1 1 220px;min-width:0;font-size:16px}
|
|
40
|
+
.seg{display:inline-flex;border:1px solid var(--line);border-radius:8px;overflow:hidden}
|
|
41
|
+
.seg button{all:unset;cursor:pointer;padding:6px 12px;font-size:12.5px;font-weight:500;color:var(--muted)}
|
|
42
|
+
.seg button+button{border-left:1px solid var(--line)}
|
|
43
|
+
.seg button[aria-pressed="true"]{background:var(--ink);color:var(--bg)}
|
|
44
|
+
.hint{font-family:var(--font-mono);font-size:11px;color:var(--muted)}
|
|
45
|
+
.content{padding:24px}
|
|
46
|
+
.chapter{max-width:1900px;margin:0 auto}
|
|
47
|
+
.chapter-head h2{margin:0 0 6px;font-size:26px;line-height:1.25;text-wrap:balance}
|
|
48
|
+
.meta{margin:0 0 18px;color:var(--muted);font-size:13px}
|
|
49
|
+
.stage-col{background:var(--stage);border-radius:12px;padding:20px;margin-bottom:24px;min-width:0}
|
|
50
|
+
.text-col{min-width:0}
|
|
51
|
+
.prose{max-width:46rem;min-width:0}
|
|
52
|
+
.prose h3{font-size:19px;margin:1.8em 0 .5em}
|
|
53
|
+
.prose h4{font-size:16px;margin:1.4em 0 .4em}
|
|
54
|
+
.prose p,.prose li{overflow-wrap:anywhere}
|
|
55
|
+
.prose table{display:block;overflow-x:auto;border-collapse:collapse;max-width:100%}
|
|
56
|
+
.prose th,.prose td{border:1px solid var(--line);padding:6px 10px;text-align:left;vertical-align:top}
|
|
57
|
+
.prose pre{overflow-x:auto;background:var(--code-bg);padding:12px;border-radius:8px}
|
|
58
|
+
.shot{margin:1.2em 0}
|
|
59
|
+
.shot img{display:block;border:1px solid var(--line);border-radius:8px}
|
|
60
|
+
.shot figcaption,.frame figcaption{font-family:var(--font-mono);font-size:11px;color:var(--muted);margin-top:6px}
|
|
61
|
+
.frame figcaption{text-transform:uppercase;letter-spacing:.04em;margin:0 0 8px}
|
|
62
|
+
.badge{font-family:var(--font-mono);font-size:11px;border:1px solid var(--line);border-radius:99px;padding:1px 8px;margin-right:6px}
|
|
63
|
+
.notice{font-size:13px;color:var(--muted);background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:6px 10px}
|
|
64
|
+
.gap-box{background:var(--warn-bg);color:var(--warn);border-radius:8px;padding:12px 14px;max-width:46rem}
|
|
65
|
+
.frames{display:flex;flex-wrap:wrap;gap:24px;align-items:flex-start;justify-content:center}
|
|
66
|
+
.frame{margin:0;min-width:0;max-width:100%}
|
|
67
|
+
.dev{background:var(--frame);padding:10px;border-radius:14px;box-shadow:0 10px 30px rgba(0,0,0,.18)}
|
|
68
|
+
.dev.desktop{padding-top:26px;border-radius:10px;position:relative}
|
|
69
|
+
.dev.desktop::before{content:"";position:absolute;top:10px;left:12px;width:8px;height:8px;border-radius:50%;background:#ff5f57;box-shadow:14px 0 #febc2e,28px 0 #28c840}
|
|
70
|
+
.dev.mobile{border-radius:26px}
|
|
71
|
+
.dev img{display:block;width:100%;border-radius:4px}
|
|
72
|
+
.dev.mobile img{border-radius:18px}
|
|
73
|
+
.dev a{display:block}
|
|
74
|
+
html[data-vp="desktop"] .frame:not([data-vp="desktop"]),
|
|
75
|
+
html[data-vp="tablet"] .frame:not([data-vp="tablet"]),
|
|
76
|
+
html[data-vp="mobile"] .frame:not([data-vp="mobile"]){display:none}
|
|
77
|
+
html:not([data-vp="all"]) .frame[data-vp="desktop"]{width:min(1600px,100%)}
|
|
78
|
+
html:not([data-vp="all"]) .frame[data-vp="tablet"]{width:min(880px,100%)}
|
|
79
|
+
html:not([data-vp="all"]) .frame[data-vp="mobile"]{width:min(390px,100%)}
|
|
80
|
+
html[data-vp="all"] .frame[data-vp="desktop"]{width:min(720px,100%)}
|
|
81
|
+
html[data-vp="all"] .frame[data-vp="tablet"]{width:min(460px,100%)}
|
|
82
|
+
html[data-vp="all"] .frame[data-vp="mobile"]{width:min(240px,100%)}
|
|
83
|
+
.lb-close{position:fixed;top:12px;right:16px;z-index:2;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:6px 14px;font:inherit;cursor:pointer}
|
|
84
|
+
#lightbox{width:100vw;height:100vh;max-width:none;max-height:none;padding:0;border:0;background:rgba(8,10,14,.88)}
|
|
85
|
+
#lightbox::backdrop{background:rgba(8,10,14,.88)}
|
|
86
|
+
.lb-scroll{position:absolute;inset:0;overflow:auto;padding:56px 16px 24px;text-align:center}
|
|
87
|
+
.lb-scroll img{max-width:100%;height:auto;cursor:zoom-out;border-radius:6px;background:#fff}
|
|
88
|
+
a[data-large]{cursor:zoom-in}
|
|
89
|
+
.steps{display:grid;gap:32px;margin-top:8px}
|
|
90
|
+
.step h3{margin:0 0 10px;font-size:19px}
|
|
91
|
+
.step-text{margin-top:14px}
|
|
92
|
+
.stat-row{display:flex;flex-wrap:wrap;gap:12px;margin:0 0 20px}
|
|
93
|
+
.stat{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:12px 18px;min-width:120px}
|
|
94
|
+
.stat b{display:block;font-size:28px;line-height:1.1}
|
|
95
|
+
.stat span{color:var(--muted);font-size:13px}
|
|
96
|
+
.stat.warn b{color:var(--warn)}
|
|
97
|
+
.mobile-pick{display:none}
|
|
98
|
+
@media (max-width:760px){
|
|
99
|
+
.app{grid-template-columns:minmax(0,1fr)}
|
|
100
|
+
aside{position:static;height:auto;border-right:0;border-bottom:1px solid var(--line)}
|
|
101
|
+
nav{display:none}
|
|
102
|
+
.mobile-pick{display:block;padding:0 16px}
|
|
103
|
+
.mobile-pick label{display:block;font-family:var(--font-mono);font-size:11px;color:var(--muted);margin-bottom:6px}
|
|
104
|
+
.mobile-pick select{width:100%;padding:8px;font:inherit;background:var(--bg);color:var(--ink);border:1px solid var(--line);border-radius:8px}
|
|
105
|
+
.brand{padding:16px 16px 12px;border-bottom:0}
|
|
106
|
+
.bar .title{display:none}
|
|
107
|
+
.bar{padding:10px 16px;position:static}
|
|
108
|
+
.content{padding:16px}
|
|
109
|
+
.stage-col{padding:12px}
|
|
110
|
+
.hint{display:none}
|
|
111
|
+
}
|
|
112
|
+
`;
|
|
113
|
+
|
|
114
|
+
export const PAGE_SCRIPT = `
|
|
115
|
+
const VIEWPORTS = Object.entries(VIEWPORT_LABELS);
|
|
116
|
+
const store = {
|
|
117
|
+
get(key){try{return localStorage.getItem(key)}catch{return null}},
|
|
118
|
+
set(key,value){try{localStorage.setItem(key,value)}catch{}}
|
|
119
|
+
};
|
|
120
|
+
const root = document.documentElement;
|
|
121
|
+
let availableViewports = VIEWPORTS;
|
|
122
|
+
const sections = [...document.querySelectorAll("section.chapter")];
|
|
123
|
+
const ids = sections.map(s => s.dataset.nav);
|
|
124
|
+
const savedLocale = store.get("hb-locale");
|
|
125
|
+
const savedViewport = store.get("hb-vp");
|
|
126
|
+
const defaultViewport = matchMedia("(max-width:760px)").matches ? "mobile" : "desktop";
|
|
127
|
+
const state = {
|
|
128
|
+
locale: LOCALES.includes(savedLocale) ? savedLocale : DEFAULT_LOCALE,
|
|
129
|
+
vp: VIEWPORTS.some(v => v[0] === savedViewport) ? savedViewport : defaultViewport,
|
|
130
|
+
id: ids.includes(location.hash.slice(1)) ? location.hash.slice(1) : ids[0]
|
|
131
|
+
};
|
|
132
|
+
function segment(el, options, key, labelOf, pressedValue){
|
|
133
|
+
el.textContent = "";
|
|
134
|
+
for (const option of options){
|
|
135
|
+
const value = option[0];
|
|
136
|
+
const button = document.createElement("button");
|
|
137
|
+
button.type = "button";
|
|
138
|
+
button.textContent = labelOf(option);
|
|
139
|
+
button.setAttribute("aria-pressed", String((pressedValue !== undefined ? pressedValue : state[key]) === value));
|
|
140
|
+
button.onclick = () => { state[key] = value; store.set(key === "locale" ? "hb-locale" : "hb-vp", value); render(); };
|
|
141
|
+
el.append(button);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function go(id){ state.id = id; history.replaceState(null, "", "#" + id); render(); window.scrollTo(0, 0); }
|
|
145
|
+
function render(){
|
|
146
|
+
root.dataset.locale = state.locale;
|
|
147
|
+
root.dataset.vp = state.vp;
|
|
148
|
+
root.lang = state.locale;
|
|
149
|
+
let active = null;
|
|
150
|
+
for (const section of sections){
|
|
151
|
+
section.hidden = section.dataset.nav !== state.id;
|
|
152
|
+
if (!section.hidden) active = section;
|
|
153
|
+
}
|
|
154
|
+
for (const link of document.querySelectorAll("[data-nav-link]")){
|
|
155
|
+
const on = link.dataset.navLink === state.id;
|
|
156
|
+
if (on) link.setAttribute("aria-current", "true"); else link.removeAttribute("aria-current");
|
|
157
|
+
}
|
|
158
|
+
document.getElementById("pick").value = state.id;
|
|
159
|
+
const heading = active && active.querySelector('.chapter-head [data-loc="' + state.locale + '"] h2, .chapter-head > h2');
|
|
160
|
+
document.getElementById("bar-title").textContent = heading ? heading.textContent : "";
|
|
161
|
+
const hasMatrix = Boolean(active && active.dataset.matrix);
|
|
162
|
+
segment(document.getElementById("seg-locale"), LOCALES.map(l => [l]), "locale", o => o[0].toUpperCase());
|
|
163
|
+
const present = ((active && active.dataset.vps) || "").split(" ").filter(Boolean);
|
|
164
|
+
const options = VIEWPORTS.filter(v => v[0] === "all" ? present.length > 1 : present.includes(v[0]));
|
|
165
|
+
availableViewports = options;
|
|
166
|
+
const effective = options.some(v => v[0] === state.vp) ? state.vp : (options[0] ? options[0][0] : state.vp);
|
|
167
|
+
root.dataset.vp = effective;
|
|
168
|
+
segment(document.getElementById("seg-vp"), options, "vp", o => o[1], effective);
|
|
169
|
+
document.getElementById("seg-vp").hidden = !hasMatrix;
|
|
170
|
+
document.getElementById("seg-locale").hidden = LOCALES.length < 2;
|
|
171
|
+
const activeLink = document.querySelector('nav [aria-current="true"]');
|
|
172
|
+
if (activeLink) activeLink.scrollIntoView({block: "nearest"});
|
|
173
|
+
}
|
|
174
|
+
window.addEventListener("hashchange", () => {
|
|
175
|
+
const id = location.hash.slice(1);
|
|
176
|
+
if (ids.includes(id) && id !== state.id){ state.id = id; render(); window.scrollTo(0, 0); }
|
|
177
|
+
});
|
|
178
|
+
const lightbox = document.getElementById("lightbox");
|
|
179
|
+
const lightboxImage = document.getElementById("lb-img");
|
|
180
|
+
function closeLightbox(){ if (lightbox.open) lightbox.close(); lightboxImage.removeAttribute("src"); }
|
|
181
|
+
document.addEventListener("click", e => {
|
|
182
|
+
const link = e.target.closest("a[data-large]");
|
|
183
|
+
if (!link || e.metaKey || e.ctrlKey || e.shiftKey || typeof lightbox.showModal !== "function") return;
|
|
184
|
+
e.preventDefault();
|
|
185
|
+
lightboxImage.alt = link.querySelector("img").alt;
|
|
186
|
+
lightboxImage.src = link.dataset.large;
|
|
187
|
+
lightbox.showModal();
|
|
188
|
+
lightbox.querySelector(".lb-scroll").scrollTop = 0;
|
|
189
|
+
});
|
|
190
|
+
document.getElementById("lb-close").onclick = closeLightbox;
|
|
191
|
+
document.getElementById("lb-scroll").addEventListener("click", () => closeLightbox());
|
|
192
|
+
lightbox.addEventListener("close", () => lightboxImage.removeAttribute("src"));
|
|
193
|
+
document.getElementById("pick").onchange = e => go(e.target.value);
|
|
194
|
+
document.getElementById("nav").addEventListener("click", e => {
|
|
195
|
+
const link = e.target.closest("[data-nav-link]");
|
|
196
|
+
if (link){ e.preventDefault(); go(link.dataset.navLink); }
|
|
197
|
+
});
|
|
198
|
+
addEventListener("keydown", e => {
|
|
199
|
+
if (lightbox.open || e.target.closest("select") || e.metaKey || e.ctrlKey || e.altKey) return;
|
|
200
|
+
const i = ids.indexOf(state.id);
|
|
201
|
+
if (e.key === "ArrowDown" && i < ids.length - 1){ e.preventDefault(); go(ids[i + 1]); }
|
|
202
|
+
else if (e.key === "ArrowUp" && i > 0){ e.preventDefault(); go(ids[i - 1]); }
|
|
203
|
+
else if (e.key.length === 1 && "1234".includes(e.key)){
|
|
204
|
+
const active = sections.find(s => !s.hidden);
|
|
205
|
+
if (active && active.dataset.matrix){ const pick = availableViewports[Number(e.key) - 1]; if (!pick) return; state.vp = pick[0]; store.set("hb-vp", state.vp); render(); }
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
render();
|
|
209
|
+
`;
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { raw } from "@cosmicdrift/kumiko-headless";
|
|
3
|
+
import { Marked } from "marked";
|
|
4
|
+
import { type BuildInfo, buildInfoLines } from "./buildinfo";
|
|
5
|
+
import { chromeFor } from "./chrome";
|
|
6
|
+
import { splitStepSections } from "./flows";
|
|
7
|
+
import {
|
|
8
|
+
type Chapter,
|
|
9
|
+
chapterTitle,
|
|
10
|
+
hasMatrix,
|
|
11
|
+
inlineImageDest,
|
|
12
|
+
isLocalImageRef,
|
|
13
|
+
isTextMissing,
|
|
14
|
+
type LocaleText,
|
|
15
|
+
type MatrixShots,
|
|
16
|
+
type Model,
|
|
17
|
+
missingImagesOf,
|
|
18
|
+
resolveImagePath,
|
|
19
|
+
staleTermsOf,
|
|
20
|
+
VIEWPORT_WIDTH,
|
|
21
|
+
VIEWPORTS,
|
|
22
|
+
type Viewport,
|
|
23
|
+
} from "./model";
|
|
24
|
+
import { PAGE_SCRIPT, PAGE_STYLES } from "./page-assets";
|
|
25
|
+
|
|
26
|
+
export type ImageJob = { src: string; width: number; quality: number };
|
|
27
|
+
|
|
28
|
+
const WEBP_QUALITY = 80;
|
|
29
|
+
const LARGE_MAX_WIDTH = 2400;
|
|
30
|
+
|
|
31
|
+
function registerImage(images: ImagePlan, dest: string, src: string, width: number): string {
|
|
32
|
+
images.jobs.set(dest, { src, width, quality: WEBP_QUALITY });
|
|
33
|
+
if (!images.withLarge) return dest;
|
|
34
|
+
const large = dest.replace(/\.webp$/, ".large.webp");
|
|
35
|
+
images.jobs.set(large, {
|
|
36
|
+
src,
|
|
37
|
+
width: LARGE_MAX_WIDTH,
|
|
38
|
+
quality: WEBP_QUALITY,
|
|
39
|
+
});
|
|
40
|
+
return large;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type ImagePlan = { jobs: Map<string, ImageJob>; withLarge: boolean };
|
|
44
|
+
|
|
45
|
+
export function createImagePlan(withLarge: boolean): ImagePlan {
|
|
46
|
+
return { jobs: new Map(), withLarge };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function escapeHtml(value: string): string {
|
|
50
|
+
return value
|
|
51
|
+
.replace(/&/g, "&")
|
|
52
|
+
.replace(/</g, "<")
|
|
53
|
+
.replace(/>/g, ">")
|
|
54
|
+
.replace(/"/g, """)
|
|
55
|
+
.replace(/'/g, "'");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function missingImageBox(href: string, locale: string): string {
|
|
59
|
+
return `<p class="gap-box gap-image"><span class="tag warn">${escapeHtml(chromeFor(locale).imageMissing)}</span> <code>${escapeHtml(href)}</code></p>`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function renderMarkdown(
|
|
63
|
+
bodyMd: string,
|
|
64
|
+
context: {
|
|
65
|
+
locale: string;
|
|
66
|
+
textsRoot: string;
|
|
67
|
+
images: ImagePlan;
|
|
68
|
+
chapterIds: ReadonlySet<string>;
|
|
69
|
+
},
|
|
70
|
+
): string {
|
|
71
|
+
const marked = new Marked({
|
|
72
|
+
gfm: true,
|
|
73
|
+
renderer: {
|
|
74
|
+
image({ href, text }) {
|
|
75
|
+
const alt = escapeHtml(text ?? "");
|
|
76
|
+
if (!isLocalImageRef(href))
|
|
77
|
+
return `<img src="${escapeHtml(href)}" alt="${alt}" loading="lazy">`;
|
|
78
|
+
const source = resolveImagePath(context.textsRoot, href);
|
|
79
|
+
if (!existsSync(source)) return missingImageBox(href, context.locale);
|
|
80
|
+
const dest = inlineImageDest(context.locale, context.textsRoot, source);
|
|
81
|
+
const large = registerImage(context.images, dest, source, 1600);
|
|
82
|
+
return `<figure class="shot"><a href="${escapeHtml(large)}" data-large="${escapeHtml(large)}" target="_blank" rel="noopener"><img src="${escapeHtml(dest)}" alt="${alt}" loading="lazy"></a>${text ? `<figcaption>${alt}</figcaption>` : ""}</figure>`;
|
|
83
|
+
},
|
|
84
|
+
paragraph({ tokens }) {
|
|
85
|
+
const innerHtml = this.parser.parseInline(tokens);
|
|
86
|
+
return tokens.length === 1 && tokens[0]?.type === "image"
|
|
87
|
+
? `${innerHtml}\n`
|
|
88
|
+
: `<p>${innerHtml}</p>\n`;
|
|
89
|
+
},
|
|
90
|
+
heading({ tokens, depth }) {
|
|
91
|
+
const level = Math.min(depth + 1, 6);
|
|
92
|
+
return `<h${level}>${this.parser.parseInline(tokens)}</h${level}>\n`;
|
|
93
|
+
},
|
|
94
|
+
link({ href, tokens }) {
|
|
95
|
+
const innerHtml = this.parser.parseInline(tokens);
|
|
96
|
+
const target = href.replace(/\.md$/, "");
|
|
97
|
+
if (href.endsWith(".md") && context.chapterIds.has(target))
|
|
98
|
+
return `<a href="#${escapeHtml(target)}">${innerHtml}</a>`;
|
|
99
|
+
return `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${innerHtml}</a>`;
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
return marked.parse(bodyMd, { async: false });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function pickShots(
|
|
107
|
+
shotsByLocale: Map<string, MatrixShots>,
|
|
108
|
+
locale: string,
|
|
109
|
+
defaultLocale: string,
|
|
110
|
+
): { shots: MatrixShots; shotsLocale: string } | undefined {
|
|
111
|
+
for (const candidate of [locale, defaultLocale, ...shotsByLocale.keys()]) {
|
|
112
|
+
const shots = shotsByLocale.get(candidate);
|
|
113
|
+
if (shots) return { shots, shotsLocale: candidate };
|
|
114
|
+
}
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function framesHtml(
|
|
119
|
+
destBase: string,
|
|
120
|
+
altBase: string,
|
|
121
|
+
shotsByLocale: Map<string, MatrixShots>,
|
|
122
|
+
model: Model,
|
|
123
|
+
images: ImagePlan,
|
|
124
|
+
): string {
|
|
125
|
+
return model.locales
|
|
126
|
+
.map((locale) => {
|
|
127
|
+
const picked = pickShots(shotsByLocale, locale, model.defaultLocale);
|
|
128
|
+
if (!picked) return "";
|
|
129
|
+
const notice =
|
|
130
|
+
picked.shotsLocale === locale
|
|
131
|
+
? ""
|
|
132
|
+
: `<p class="notice">${escapeHtml(chromeFor(model.defaultLocale).shotsFallbackNotice(picked.shotsLocale.toUpperCase()))}</p>`;
|
|
133
|
+
const frames = VIEWPORTS.flatMap((viewport) => {
|
|
134
|
+
const source = picked.shots[viewport];
|
|
135
|
+
if (!source) return [];
|
|
136
|
+
const dest = `shots/${destBase}/${picked.shotsLocale}/${viewport}.webp`;
|
|
137
|
+
const large = registerImage(images, dest, source, VIEWPORT_WIDTH[viewport]);
|
|
138
|
+
const labelHtml = escapeHtml(chromeFor(model.defaultLocale).viewports[viewport]);
|
|
139
|
+
return [
|
|
140
|
+
`<figure class="frame" data-vp="${viewport}"><figcaption>${labelHtml}</figcaption><div class="dev ${viewport}"><a href="${escapeHtml(large)}" data-large="${escapeHtml(large)}" target="_blank" rel="noopener"><img src="${escapeHtml(dest)}" alt="${escapeHtml(altBase)} (${labelHtml})" loading="lazy"></a></div></figure>`,
|
|
141
|
+
];
|
|
142
|
+
});
|
|
143
|
+
return `<div class="stage-loc" data-loc="${escapeHtml(locale)}">${notice}<div class="frames">${raw(frames.join(""))}</div></div>`;
|
|
144
|
+
})
|
|
145
|
+
.join("\n");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function availableViewports(chapter: Chapter): Viewport[] {
|
|
149
|
+
const all = [chapter.matrix, ...(chapter.steps ?? []).map((step) => step.shots)];
|
|
150
|
+
return VIEWPORTS.filter((viewport) =>
|
|
151
|
+
all.some((byLocale) => [...byLocale.values()].some((shots) => shots[viewport])),
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function stepsHtml(chapter: Chapter, model: Model, images: ImagePlan): string {
|
|
156
|
+
const steps = chapter.steps ?? [];
|
|
157
|
+
const slugs = new Set(steps.map((step) => step.slug));
|
|
158
|
+
const chapterIds = new Set(model.groups.flatMap((g) => g.chapters.map((c) => c.id)));
|
|
159
|
+
return steps
|
|
160
|
+
.map((step) => {
|
|
161
|
+
const texts = model.locales
|
|
162
|
+
.map((locale) => {
|
|
163
|
+
const own = chapter.texts.get(locale);
|
|
164
|
+
const text = own ?? chapter.texts.get(model.defaultLocale);
|
|
165
|
+
const section = text
|
|
166
|
+
? splitStepSections(text.bodyMd, slugs).sections.get(step.slug)
|
|
167
|
+
: undefined;
|
|
168
|
+
const body =
|
|
169
|
+
text && section
|
|
170
|
+
? renderMarkdown(section, {
|
|
171
|
+
locale: own ? locale : model.defaultLocale,
|
|
172
|
+
textsRoot: text.dir,
|
|
173
|
+
images,
|
|
174
|
+
chapterIds,
|
|
175
|
+
})
|
|
176
|
+
: "";
|
|
177
|
+
return `<div data-loc="${escapeHtml(locale)}">${body ? `<div class="prose step-text">${raw(body)}</div>` : ""}</div>`;
|
|
178
|
+
})
|
|
179
|
+
.join("");
|
|
180
|
+
return `<article class="step"><h3>${escapeHtml(step.label)}</h3><div class="stage-col">${framesHtml(`${chapter.id}/${step.id}`, `${step.label}`, step.shots, model, images)}</div>${raw(texts)}</article>`;
|
|
181
|
+
})
|
|
182
|
+
.join("\n");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function localeTextHtml(chapter: Chapter, locale: string, model: Model, images: ImagePlan): string {
|
|
186
|
+
const c = chromeFor(model.defaultLocale);
|
|
187
|
+
const own = chapter.texts.get(locale);
|
|
188
|
+
const text: LocaleText | undefined = own ?? chapter.texts.get(model.defaultLocale);
|
|
189
|
+
if (!text) {
|
|
190
|
+
return `<div class="gap-box"><span class="tag warn">${raw(c.textMissing)}</span> ${raw(c.textMissingBody)}</div>`;
|
|
191
|
+
}
|
|
192
|
+
const textLocale = own ? locale : model.defaultLocale;
|
|
193
|
+
const notice = own
|
|
194
|
+
? ""
|
|
195
|
+
: `<p class="notice">${escapeHtml(c.textFallbackNotice(model.defaultLocale.toUpperCase()))}</p>`;
|
|
196
|
+
const badges = text.prozesse.map((p) => `<span class="badge">${escapeHtml(p)}</span>`).join("");
|
|
197
|
+
const chapterIds = new Set(model.groups.flatMap((g) => g.chapters.map((c) => c.id)));
|
|
198
|
+
const bodyMd = chapter.steps
|
|
199
|
+
? splitStepSections(text.bodyMd, new Set(chapter.steps.map((step) => step.slug))).intro
|
|
200
|
+
: text.bodyMd;
|
|
201
|
+
const body = renderMarkdown(bodyMd, {
|
|
202
|
+
locale: textLocale,
|
|
203
|
+
textsRoot: text.dir,
|
|
204
|
+
images,
|
|
205
|
+
chapterIds,
|
|
206
|
+
});
|
|
207
|
+
const stale =
|
|
208
|
+
text.staleTerms.length > 0
|
|
209
|
+
? `<div class="gap-box"><span class="tag warn">${raw(c.textStale)}</span> ${raw(c.staleBody)} ${text.staleTerms.map((term) => `<code>${escapeHtml(term)}</code>`).join(", ")}</div>`
|
|
210
|
+
: "";
|
|
211
|
+
return `${notice}${stale}${badges ? `<p class="badges">${badges}</p>` : ""}<div class="prose">${raw(body)}</div>`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function chapterHtml(chapter: Chapter, model: Model, images: ImagePlan): string {
|
|
215
|
+
const c = chromeFor(model.defaultLocale);
|
|
216
|
+
const matrix = hasMatrix(chapter);
|
|
217
|
+
const missing = missingImagesOf(chapter);
|
|
218
|
+
const flags = [
|
|
219
|
+
isTextMissing(chapter) ? `<span class="tag warn">${raw(c.textMissing)}</span>` : "",
|
|
220
|
+
missing.length > 0
|
|
221
|
+
? `<span class="tag warn">${raw(c.imageMissing)} (${missing.length})</span>`
|
|
222
|
+
: "",
|
|
223
|
+
staleTermsOf(chapter).length > 0 ? `<span class="tag warn">${raw(c.textStale)}</span>` : "",
|
|
224
|
+
].join("");
|
|
225
|
+
const head = model.locales
|
|
226
|
+
.map(
|
|
227
|
+
(locale) =>
|
|
228
|
+
`<div data-loc="${escapeHtml(locale)}"><h2>${escapeHtml(chapterTitle(chapter, locale, model.defaultLocale))}</h2></div>`,
|
|
229
|
+
)
|
|
230
|
+
.join("");
|
|
231
|
+
const text = model.locales
|
|
232
|
+
.map(
|
|
233
|
+
(locale) =>
|
|
234
|
+
`<div data-loc="${escapeHtml(locale)}">${localeTextHtml(chapter, locale, model, images)}</div>`,
|
|
235
|
+
)
|
|
236
|
+
.join("\n");
|
|
237
|
+
return `<section class="chapter${matrix && !chapter.steps ? " has-matrix" : ""}${isTextMissing(chapter) ? " no-text" : ""}" id="${escapeHtml(chapter.id)}" data-nav="${escapeHtml(chapter.id)}"${raw(matrix ? ` data-matrix="1" data-vps="${availableViewports(chapter).join(" ")}"` : "")}>
|
|
238
|
+
<header class="chapter-head">${head}<p class="meta"><code>${escapeHtml(chapter.id)}</code>${raw(flags)}</p></header>
|
|
239
|
+
${matrix && !chapter.steps ? `<div class="stage-col">${framesHtml(chapter.id, chapter.id, chapter.matrix, model, images)}</div>` : ""}
|
|
240
|
+
<div class="text-col">${text}</div>
|
|
241
|
+
${chapter.steps ? `<div class="steps">${stepsHtml(chapter, model, images)}</div>` : ""}
|
|
242
|
+
</section>`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function overviewHtml(model: Model): string {
|
|
246
|
+
const { stats } = model;
|
|
247
|
+
const c = chromeFor(model.defaultLocale);
|
|
248
|
+
const list = (title: string, chapters: Chapter[], detail?: (c: Chapter) => string) =>
|
|
249
|
+
chapters.length === 0
|
|
250
|
+
? ""
|
|
251
|
+
: `<h3>${raw(title)}</h3><ul class="gap-list">${chapters
|
|
252
|
+
.map(
|
|
253
|
+
(c) =>
|
|
254
|
+
`<li><a href="#${escapeHtml(c.id)}">${escapeHtml(chapterTitle(c, model.defaultLocale, model.defaultLocale))}</a>${detail ? ` <code>${escapeHtml(detail(c))}</code>` : ""}</li>`,
|
|
255
|
+
)
|
|
256
|
+
.join("")}</ul>`;
|
|
257
|
+
const documented = model.groups.flatMap((group) => group.chapters);
|
|
258
|
+
return `<section class="chapter overview" id="uebersicht" data-nav="uebersicht">
|
|
259
|
+
<header class="chapter-head"><h2>${raw(c.overview)}</h2></header>
|
|
260
|
+
<div class="text-col"><div class="prose">
|
|
261
|
+
<div class="stat-row">
|
|
262
|
+
<div class="stat"><b>${stats.chapters}</b><span>${raw(c.chapters)}</span></div>
|
|
263
|
+
<div class="stat${stats.textMissing ? " warn" : ""}"><b>${stats.textMissing}</b><span>${raw(c.textMissing)}</span></div>
|
|
264
|
+
<div class="stat${stats.imageMissing ? " warn" : ""}"><b>${stats.imageMissing}</b><span>${raw(c.imageMissing)}</span></div>
|
|
265
|
+
<div class="stat${stats.staleText ? " warn" : ""}"><b>${stats.staleText}</b><span>${raw(c.textStale)}</span></div>
|
|
266
|
+
<div class="stat"><b>${stats.planned}</b><span>${raw(c.planned)}</span></div>
|
|
267
|
+
</div>
|
|
268
|
+
${list(c.textMissing, documented.filter(isTextMissing))}
|
|
269
|
+
${list(
|
|
270
|
+
c.textStale,
|
|
271
|
+
documented.filter((c) => staleTermsOf(c).length > 0),
|
|
272
|
+
(c) => staleTermsOf(c).join(", "),
|
|
273
|
+
)}
|
|
274
|
+
${list(
|
|
275
|
+
c.imageMissing,
|
|
276
|
+
documented.filter((c) => missingImagesOf(c).length > 0),
|
|
277
|
+
(c) => missingImagesOf(c).join(", "),
|
|
278
|
+
)}
|
|
279
|
+
</div></div></section>`;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function plannedHtml(model: Model): string {
|
|
283
|
+
const c = chromeFor(model.defaultLocale);
|
|
284
|
+
const [colScreen, colId, colGroup] = c.plannedColumns;
|
|
285
|
+
const rows = model.planned
|
|
286
|
+
.map(
|
|
287
|
+
({ chapter, groupLabel }) =>
|
|
288
|
+
`<tr><td>${escapeHtml(chapter.screenTitle ?? chapter.id)}</td><td><code>${escapeHtml(chapter.id)}</code></td><td>${escapeHtml(groupLabel)}</td></tr>`,
|
|
289
|
+
)
|
|
290
|
+
.join("");
|
|
291
|
+
const table =
|
|
292
|
+
model.planned.length === 0
|
|
293
|
+
? `<p>${raw(c.plannedNone)}</p>`
|
|
294
|
+
: `<table><thead><tr><th>${raw(colScreen)}</th><th>${raw(colId)}</th><th>${raw(colGroup)}</th></tr></thead><tbody>${rows}</tbody></table>`;
|
|
295
|
+
return `<section class="chapter" id="geplante-masken" data-nav="geplante-masken">
|
|
296
|
+
<header class="chapter-head"><h2>${raw(c.planned)}</h2></header>
|
|
297
|
+
<div class="text-col"><div class="prose"><p>${raw(c.plannedIntro)}</p>${table}</div></div></section>`;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function navHtml(model: Model): { nav: string; select: string } {
|
|
301
|
+
const c = chromeFor(model.defaultLocale);
|
|
302
|
+
const link = (id: string, label: string, tags = "") =>
|
|
303
|
+
`<a href="#${escapeHtml(id)}" data-nav-link="${escapeHtml(id)}"><span>${escapeHtml(label)}</span>${raw(tags)}</a>`;
|
|
304
|
+
const option = (id: string, label: string) =>
|
|
305
|
+
`<option value="${escapeHtml(id)}">${escapeHtml(label)}</option>`;
|
|
306
|
+
const navParts = [link("uebersicht", c.overview)];
|
|
307
|
+
const selectParts = [option("uebersicht", c.overview)];
|
|
308
|
+
for (const group of model.groups) {
|
|
309
|
+
navParts.push(`<h3>${escapeHtml(group.label)}</h3>`);
|
|
310
|
+
selectParts.push(`<optgroup label="${escapeHtml(group.label)}">`);
|
|
311
|
+
for (const chapter of group.chapters) {
|
|
312
|
+
const title = chapterTitle(chapter, model.defaultLocale, model.defaultLocale);
|
|
313
|
+
const tags =
|
|
314
|
+
(isTextMissing(chapter) ? `<span class="tag warn">${raw(c.textMissing)}</span>` : "") +
|
|
315
|
+
(missingImagesOf(chapter).length > 0
|
|
316
|
+
? `<span class="tag warn">${raw(c.imageMissing)}</span>`
|
|
317
|
+
: "") +
|
|
318
|
+
(staleTermsOf(chapter).length > 0
|
|
319
|
+
? `<span class="tag warn">${raw(c.textStale)}</span>`
|
|
320
|
+
: "");
|
|
321
|
+
navParts.push(link(chapter.id, title, tags));
|
|
322
|
+
selectParts.push(option(chapter.id, title));
|
|
323
|
+
}
|
|
324
|
+
selectParts.push("</optgroup>");
|
|
325
|
+
}
|
|
326
|
+
navParts.push(`<h3>${raw(c.appendix)}</h3>`, link("geplante-masken", c.planned));
|
|
327
|
+
selectParts.push(option("geplante-masken", c.planned));
|
|
328
|
+
return { nav: navParts.join("\n"), select: selectParts.join("\n") };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function jsonForScript(value: unknown): string {
|
|
332
|
+
return JSON.stringify(value).replace(/</g, "\\u003c");
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export function renderPage(
|
|
336
|
+
title: string,
|
|
337
|
+
model: Model,
|
|
338
|
+
images: ImagePlan,
|
|
339
|
+
info: BuildInfo,
|
|
340
|
+
): string {
|
|
341
|
+
const chapters = model.groups.flatMap((group) => group.chapters);
|
|
342
|
+
const sections = chapters.map((chapter) => chapterHtml(chapter, model, images)).join("\n");
|
|
343
|
+
const { nav, select } = navHtml(model);
|
|
344
|
+
const { stats } = model;
|
|
345
|
+
const c = chromeFor(model.defaultLocale);
|
|
346
|
+
const localeRules = model.locales
|
|
347
|
+
.map(
|
|
348
|
+
(locale) =>
|
|
349
|
+
`html[data-locale="${locale}"] [data-loc]:not([data-loc="${locale}"]){display:none}`,
|
|
350
|
+
)
|
|
351
|
+
.join("\n");
|
|
352
|
+
return `<!doctype html>
|
|
353
|
+
<html lang="${escapeHtml(model.defaultLocale)}" data-locale="${escapeHtml(model.defaultLocale)}" data-vp="desktop">
|
|
354
|
+
<head>
|
|
355
|
+
<meta charset="utf-8">
|
|
356
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
357
|
+
<title>${escapeHtml(title)}</title>
|
|
358
|
+
<style>
|
|
359
|
+
${PAGE_STYLES}
|
|
360
|
+
${raw(localeRules)}
|
|
361
|
+
</style>
|
|
362
|
+
</head>
|
|
363
|
+
<body>
|
|
364
|
+
<div class="app">
|
|
365
|
+
<aside>
|
|
366
|
+
<div class="brand"><h1>${escapeHtml(title)}</h1>
|
|
367
|
+
<p class="build-info">${raw(buildInfoLines(info, c.builtAt).map(escapeHtml).join(" · "))}</p>
|
|
368
|
+
<div class="stats"><span><b>${stats.chapters}</b> ${raw(c.chapters)}</span><span><b>${stats.textMissing}</b> ${raw(c.textMissing)}</span><span><b>${stats.imageMissing}</b> ${raw(c.imageMissing)}</span><span><b>${stats.staleText}</b> ${raw(c.staleShort)}</span><span><b>${stats.planned}</b> ${raw(c.plannedShort)}</span></div></div>
|
|
369
|
+
<div class="mobile-pick"><label for="pick">${raw(c.chapters)}</label><select id="pick">${raw(select)}</select></div>
|
|
370
|
+
<nav id="nav" aria-label="${raw(c.chapters)}">${raw(nav)}</nav>
|
|
371
|
+
</aside>
|
|
372
|
+
<main>
|
|
373
|
+
<div class="bar">
|
|
374
|
+
<div class="title"><b id="bar-title"></b></div>
|
|
375
|
+
<div class="seg" id="seg-locale" role="group" aria-label="${raw(c.language)}"></div>
|
|
376
|
+
<div class="seg" id="seg-vp" role="group" aria-label="${raw(c.size)}"></div>
|
|
377
|
+
<span class="hint">${raw(c.hint)}</span>
|
|
378
|
+
</div>
|
|
379
|
+
<div class="content">
|
|
380
|
+
${overviewHtml(model)}
|
|
381
|
+
${sections}
|
|
382
|
+
${plannedHtml(model)}
|
|
383
|
+
</div>
|
|
384
|
+
</main>
|
|
385
|
+
</div>
|
|
386
|
+
<dialog id="lightbox" aria-label="${raw(c.lightbox)}"><button type="button" class="lb-close" id="lb-close">${raw(c.close)}</button><div class="lb-scroll" id="lb-scroll"><img id="lb-img" alt=""></div></dialog>
|
|
387
|
+
<script>
|
|
388
|
+
const LOCALES = ${raw(jsonForScript(model.locales))};
|
|
389
|
+
const DEFAULT_LOCALE = ${raw(jsonForScript(model.defaultLocale))};
|
|
390
|
+
const VIEWPORT_LABELS = ${raw(jsonForScript(c.viewports))};
|
|
391
|
+
${PAGE_SCRIPT}
|
|
392
|
+
</script>
|
|
393
|
+
</body>
|
|
394
|
+
</html>
|
|
395
|
+
`;
|
|
396
|
+
}
|