@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.
@@ -0,0 +1,96 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { MatrixShots, Step } from "./model";
4
+
5
+ const FLOW_FILE = /^(.+?)--([a-z]{2})--(desktop|tablet|mobile)--[^.]+\.png$/;
6
+ const STEP_NUMBER_PREFIX = /^\d+[a-z]?-/;
7
+ const numericCollator = new Intl.Collator("de", { numeric: true });
8
+
9
+ export function prettifySlug(slug: string): string {
10
+ const spaced = slug.replace(/-/g, " ");
11
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
12
+ }
13
+
14
+ export function stepSlug(stepId: string): string {
15
+ return stepId.replace(STEP_NUMBER_PREFIX, "");
16
+ }
17
+
18
+ function isViewport(value: string): value is keyof MatrixShots {
19
+ return value === "desktop" || value === "tablet" || value === "mobile";
20
+ }
21
+
22
+ export function discoverFlowShots(
23
+ shotsRoot: string,
24
+ locales: readonly string[],
25
+ ): Map<string, Step[]> {
26
+ const imagesRoot = existsSync(join(shotsRoot, "images")) ? join(shotsRoot, "images") : shotsRoot;
27
+ const flows = new Map<string, Step[]>();
28
+ if (!existsSync(imagesRoot)) return flows;
29
+ const flowDirs = readdirSync(imagesRoot, { withFileTypes: true })
30
+ .filter((entry) => entry.isDirectory())
31
+ .map((entry) => entry.name)
32
+ .sort();
33
+ for (const flow of flowDirs) {
34
+ const steps = new Map<string, Step>();
35
+ for (const file of readdirSync(join(imagesRoot, flow)).sort()) {
36
+ const match = file.match(FLOW_FILE);
37
+ const stepId = match?.[1];
38
+ const locale = match?.[2];
39
+ const viewport = match?.[3];
40
+ if (!stepId || !locale || !viewport || !isViewport(viewport)) continue;
41
+ if (!locales.includes(locale)) continue;
42
+ const step = steps.get(stepId) ?? {
43
+ id: stepId,
44
+ slug: stepSlug(stepId),
45
+ label: prettifySlug(stepSlug(stepId)),
46
+ shots: new Map<string, MatrixShots>(),
47
+ };
48
+ const shots = step.shots.get(locale) ?? {};
49
+ shots[viewport] = join(imagesRoot, flow, file);
50
+ step.shots.set(locale, shots);
51
+ steps.set(stepId, step);
52
+ }
53
+ if (steps.size > 0) {
54
+ flows.set(
55
+ flow,
56
+ [...steps.values()].sort((a, b) => numericCollator.compare(a.id, b.id)),
57
+ );
58
+ }
59
+ }
60
+ return flows;
61
+ }
62
+
63
+ function headingSlug(heading: string): string {
64
+ return heading
65
+ .trim()
66
+ .toLowerCase()
67
+ .replace(/[^a-z0-9]+/g, "-")
68
+ .replace(/^-|-$/g, "")
69
+ .replace(STEP_NUMBER_PREFIX, "");
70
+ }
71
+
72
+ export function splitStepSections(
73
+ bodyMd: string,
74
+ stepSlugs: ReadonlySet<string>,
75
+ ): { intro: string; sections: Map<string, string> } {
76
+ const introLines: string[] = [];
77
+ const sections = new Map<string, string[]>();
78
+ let current: string[] = introLines;
79
+ for (const line of bodyMd.split(/\r?\n/)) {
80
+ const heading = line.match(/^##\s+(.+?)\s*$/);
81
+ if (heading?.[1]) {
82
+ const slug = headingSlug(heading[1]);
83
+ if (stepSlugs.has(slug)) {
84
+ current = [];
85
+ sections.set(slug, current);
86
+ continue;
87
+ }
88
+ current = introLines;
89
+ }
90
+ current.push(line);
91
+ }
92
+ return {
93
+ intro: introLines.join("\n"),
94
+ sections: new Map([...sections].map(([slug, lines]) => [slug, lines.join("\n")])),
95
+ };
96
+ }
@@ -0,0 +1,574 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { basename, dirname, join, resolve } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { chromeFor } from "./chrome";
5
+ import type { HandbuchConfig, ScreensModuleConfig } from "./config";
6
+ import { discoverFlowShots, prettifySlug } from "./flows";
7
+ import {
8
+ buildUiIndex,
9
+ collectSourceUiStrings,
10
+ collectUiStrings,
11
+ collectYamlUiStrings,
12
+ findMissingUiTerms,
13
+ } from "./uistrings";
14
+
15
+ export const VIEWPORTS = ["desktop", "tablet", "mobile"] as const;
16
+ export type Viewport = (typeof VIEWPORTS)[number];
17
+
18
+ export const VIEWPORT_WIDTH: Record<Viewport, number> = {
19
+ desktop: 1600,
20
+ tablet: 1100,
21
+ mobile: 640,
22
+ };
23
+
24
+ const FALLBACK_GROUP_ID = "weitere";
25
+
26
+ const LIGHT_THEME_DIR = "default-light";
27
+ const LOCAL_IMAGE_REF = /\.(png|jpe?g|webp|gif|svg)$/i;
28
+
29
+ export type Group = { id: string; label: string };
30
+ export type ScreenRef = { id: string; title?: string; group?: string };
31
+ export type MatrixShots = Partial<Record<Viewport, string>>;
32
+
33
+ export type LocaleText = {
34
+ title: string;
35
+ bodyMd: string;
36
+ dir: string;
37
+ gruppe?: string;
38
+ prozesse: string[];
39
+ missingImages: string[];
40
+ uiIgnore: string[];
41
+ staleTerms: string[];
42
+ };
43
+
44
+ export type Step = {
45
+ id: string;
46
+ slug: string;
47
+ label: string;
48
+ shots: Map<string, MatrixShots>;
49
+ };
50
+
51
+ export type Chapter = {
52
+ id: string;
53
+ screenTitle?: string;
54
+ groupId: string;
55
+ texts: Map<string, LocaleText>;
56
+ matrix: Map<string, MatrixShots>;
57
+ steps?: Step[];
58
+ inScreenList: boolean;
59
+ };
60
+
61
+ export type Model = {
62
+ locales: string[];
63
+ defaultLocale: string;
64
+ groups: Array<Group & { chapters: Chapter[] }>;
65
+ planned: Array<{ chapter: Chapter; groupLabel: string }>;
66
+ stats: {
67
+ chapters: number;
68
+ textMissing: number;
69
+ imageMissing: number;
70
+ staleText: number;
71
+ planned: number;
72
+ };
73
+ };
74
+
75
+ export function hasText(chapter: Chapter): boolean {
76
+ return chapter.texts.size > 0;
77
+ }
78
+
79
+ export function hasMatrix(chapter: Chapter): boolean {
80
+ return chapter.matrix.size > 0 || (chapter.steps?.length ?? 0) > 0;
81
+ }
82
+
83
+ export function isPlanned(chapter: Chapter): boolean {
84
+ return chapter.inScreenList && !hasText(chapter) && !hasMatrix(chapter);
85
+ }
86
+
87
+ export function isTextMissing(chapter: Chapter): boolean {
88
+ return !hasText(chapter) && hasMatrix(chapter);
89
+ }
90
+
91
+ export function missingImagesOf(chapter: Chapter): string[] {
92
+ return [...chapter.texts.values()].flatMap((text) => text.missingImages);
93
+ }
94
+
95
+ export function staleTermsOf(chapter: Chapter): string[] {
96
+ return [...new Set([...chapter.texts.values()].flatMap((text) => text.staleTerms))];
97
+ }
98
+
99
+ export function chapterTitle(chapter: Chapter, locale: string, defaultLocale: string): string {
100
+ return (
101
+ chapter.texts.get(locale)?.title ||
102
+ chapter.texts.get(defaultLocale)?.title ||
103
+ chapter.screenTitle ||
104
+ chapter.id
105
+ );
106
+ }
107
+
108
+ export function parseFrontmatter(raw: string): {
109
+ data: Record<string, string>;
110
+ body: string;
111
+ } {
112
+ const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
113
+ const block = match?.[1];
114
+ const body = match?.[2];
115
+ if (block === undefined || body === undefined) return { data: {}, body: raw };
116
+ const data: Record<string, string> = {};
117
+ for (const line of block.split(/\r?\n/)) {
118
+ const pair = line.match(/^([a-zA-Z][a-zA-Z-]*):\s*(.*)$/);
119
+ const key = pair?.[1];
120
+ const value = pair?.[2];
121
+ if (key !== undefined && value !== undefined)
122
+ data[key] = value.trim().replace(/^["']|["']$/g, "");
123
+ }
124
+ return { data, body };
125
+ }
126
+
127
+ export function parseList(raw: string | undefined): string[] {
128
+ if (!raw) return [];
129
+ return raw
130
+ .replace(/^\[/, "")
131
+ .replace(/\]$/, "")
132
+ .split(",")
133
+ .map((item) => item.trim().replace(/^["']|["']$/g, ""))
134
+ .filter(Boolean);
135
+ }
136
+
137
+ export function stripLeadingHeading(body: string): string {
138
+ const lines = body.split(/\r?\n/);
139
+ let index = 0;
140
+ while (index < lines.length && (lines[index] ?? "").trim() === "") index++;
141
+ if (index < lines.length && /^#\s+/.test(lines[index] ?? "")) {
142
+ lines.splice(index, 1);
143
+ while (index < lines.length && (lines[index] ?? "").trim() === "") lines.splice(index, 1);
144
+ }
145
+ return lines.join("\n");
146
+ }
147
+
148
+ export function isLocalImageRef(href: string): boolean {
149
+ return (
150
+ !/^[a-z][a-z0-9+.-]*:|^\/\//i.test(href) && LOCAL_IMAGE_REF.test(href.split(/[?#]/)[0] ?? "")
151
+ );
152
+ }
153
+
154
+ export function localImageRefs(body: string): string[] {
155
+ const refs = new Set<string>();
156
+ for (const match of body.matchAll(/!\[[^\]]*\]\(([^)\s]+)[^)]*\)/g)) {
157
+ const href = match[1];
158
+ if (href && isLocalImageRef(href)) refs.add(href);
159
+ }
160
+ return [...refs];
161
+ }
162
+
163
+ export function resolveImagePath(dir: string, href: string): string {
164
+ return resolve(dir, decodeURI(href.split(/[?#]/)[0] ?? href));
165
+ }
166
+
167
+ export function readTexts(textsRoot: string): Map<string, LocaleText & { id: string }> {
168
+ const texts = new Map<string, LocaleText & { id: string }>();
169
+ if (!existsSync(textsRoot)) return texts;
170
+ for (const file of readdirSync(textsRoot)
171
+ .filter((name) => name.endsWith(".md"))
172
+ .sort()) {
173
+ const { data, body } = parseFrontmatter(readFileSync(join(textsRoot, file), "utf8"));
174
+ const id = data["screenId"] || basename(file, ".md");
175
+ const bodyMd = stripLeadingHeading(body);
176
+ texts.set(id, {
177
+ id,
178
+ title: data["titel"] || id,
179
+ bodyMd,
180
+ dir: textsRoot,
181
+ gruppe: data["gruppe"] || undefined,
182
+ prozesse: parseList(data["prozesse"]),
183
+ uiIgnore: parseList(data["ui-ignore"]),
184
+ staleTerms: [],
185
+ missingImages: localImageRefs(bodyMd).filter(
186
+ (href) => !existsSync(resolveImagePath(textsRoot, href)),
187
+ ),
188
+ });
189
+ }
190
+ return texts;
191
+ }
192
+
193
+ export function discoverMatrix(
194
+ shotsDir: string,
195
+ locales: readonly string[],
196
+ ): Map<string, Map<string, MatrixShots>> {
197
+ const matrix = new Map<string, Map<string, MatrixShots>>();
198
+ if (!existsSync(shotsDir)) return matrix;
199
+ for (const entry of readdirSync(shotsDir, { withFileTypes: true })) {
200
+ if (!entry.isDirectory()) continue;
201
+ const perLocale = new Map<string, MatrixShots>();
202
+ for (const locale of locales) {
203
+ const shots: MatrixShots = {};
204
+ for (const viewport of VIEWPORTS) {
205
+ const file = join(shotsDir, entry.name, locale, LIGHT_THEME_DIR, `${viewport}.png`);
206
+ if (existsSync(file)) shots[viewport] = file;
207
+ }
208
+ if (Object.keys(shots).length > 0) perLocale.set(locale, shots);
209
+ }
210
+ if (perLocale.size > 0) matrix.set(entry.name, perLocale);
211
+ }
212
+ return matrix;
213
+ }
214
+
215
+ function isRecord(value: unknown): value is Record<string, unknown> {
216
+ return typeof value === "object" && value !== null;
217
+ }
218
+
219
+ export async function loadScreensModule(
220
+ rootDir: string,
221
+ repo: string,
222
+ source: ScreensModuleConfig,
223
+ ): Promise<ScreenRef[]> {
224
+ const modulePath = resolve(rootDir, repo, source.path);
225
+ const loaded: unknown = await import(pathToFileURL(modulePath).href);
226
+ const list = isRecord(loaded) ? loaded[source.export] : undefined;
227
+ if (!Array.isArray(list)) {
228
+ throw new Error(`${modulePath}: export "${source.export}" is not an array`);
229
+ }
230
+ return list.map((entry: unknown, index) => {
231
+ if (!isRecord(entry) || typeof entry[source.idKey] !== "string") {
232
+ throw new Error(`${modulePath}: ${source.export}[${index}] has no string "${source.idKey}"`);
233
+ }
234
+ const title = entry[source.titleKey];
235
+ const group = entry[source.groupKey];
236
+ return {
237
+ id: String(entry[source.idKey]),
238
+ title: typeof title === "string" ? title : undefined,
239
+ group: typeof group === "string" ? group : undefined,
240
+ };
241
+ });
242
+ }
243
+
244
+ function slugify(label: string): string {
245
+ return label
246
+ .toLowerCase()
247
+ .replace(/[^a-z0-9]+/g, "-")
248
+ .replace(/^-|-$/g, "");
249
+ }
250
+
251
+ export type BuildModelOptions = {
252
+ rootDir: string;
253
+ shotsDir?: string;
254
+ textsDir?: string;
255
+ flowShotsDir?: string;
256
+ flowTextsDir?: string;
257
+ screens?: ScreenRef[];
258
+ };
259
+
260
+ type FlowChapter = {
261
+ ref: ScreenRef;
262
+ steps?: Step[];
263
+ shots?: Map<string, MatrixShots>;
264
+ };
265
+
266
+ type FlowSource = NonNullable<HandbuchConfig["flowSources"]>[number];
267
+
268
+ function flowChaptersOf(
269
+ config: HandbuchConfig,
270
+ source: FlowSource,
271
+ flows: Map<string, Step[]>,
272
+ ): FlowChapter[] {
273
+ const tr = config.translations?.[config.defaultLocale];
274
+ const flowChapters: FlowChapter[] = [];
275
+ for (const [flow, untitledSteps] of flows) {
276
+ const steps = untitledSteps.map((step) => ({
277
+ ...step,
278
+ label: tr?.steps[step.slug] ?? step.label,
279
+ }));
280
+ if (source.pageFlows.includes(flow)) {
281
+ for (const step of steps) {
282
+ flowChapters.push({
283
+ ref: {
284
+ id: `web-seite-${step.slug}`,
285
+ title: step.label,
286
+ group: source.pageGroup.id,
287
+ },
288
+ shots: step.shots,
289
+ });
290
+ }
291
+ } else {
292
+ flowChapters.push({
293
+ ref: {
294
+ id: `web-ablauf-${flow}`,
295
+ title: tr?.flows[flow] ?? source.flowTitles[flow] ?? prettifySlug(flow),
296
+ group: source.flowGroup.id,
297
+ },
298
+ steps,
299
+ });
300
+ }
301
+ }
302
+ return flowChapters;
303
+ }
304
+
305
+ function collectFlowSources(
306
+ config: HandbuchConfig,
307
+ options: BuildModelOptions,
308
+ textsByLocale: Map<string, Map<string, LocaleText>>,
309
+ applyStale: (
310
+ byLocale: Map<string, Map<string, LocaleText>>,
311
+ uiStrings: Map<string, string[]>,
312
+ ) => void,
313
+ labelGroup: (group: Group) => Group,
314
+ ): { flowChapters: FlowChapter[]; flowGroups: Group[] } {
315
+ const flowChapters: FlowChapter[] = [];
316
+ const flowGroups: Group[] = [];
317
+ for (const source of config.flowSources ?? []) {
318
+ const sourceRepo = resolve(options.rootDir, source.repo);
319
+ flowGroups.push(labelGroup(source.pageGroup), labelGroup(source.flowGroup));
320
+ const sourceTexts = new Map(
321
+ config.locales.map((locale) => [
322
+ locale,
323
+ readTexts(
324
+ options.flowTextsDir
325
+ ? join(options.flowTextsDir, locale)
326
+ : join(sourceRepo, source.textsDir.replace("{locale}", locale)),
327
+ ),
328
+ ]),
329
+ );
330
+ if (source.uiStringsByLocale || source.uiStringFiles) {
331
+ const strings = collectYamlUiStrings(sourceRepo, source.uiStringsByLocale ?? {});
332
+ const inline = collectSourceUiStrings(sourceRepo, source.uiStringFiles ?? [], config.locales);
333
+ for (const [locale, values] of inline)
334
+ strings.set(locale, [...(strings.get(locale) ?? []), ...values]);
335
+ applyStale(sourceTexts, strings);
336
+ }
337
+ for (const [locale, texts] of sourceTexts) {
338
+ for (const [id, text] of texts) textsByLocale.get(locale)?.set(id, text);
339
+ }
340
+ const flowRoot =
341
+ options.flowShotsDir ??
342
+ (source.shotsDir ? resolve(options.rootDir, source.shotsDir) : undefined);
343
+ const flows = flowRoot
344
+ ? discoverFlowShots(flowRoot, config.locales)
345
+ : new Map<string, Step[]>();
346
+ flowChapters.push(...flowChaptersOf(config, source, flows));
347
+ }
348
+ return { flowChapters, flowGroups };
349
+ }
350
+
351
+ function registerChapters(
352
+ screens: ScreenRef[],
353
+ flowChapters: FlowChapter[],
354
+ textsByLocale: Map<string, Map<string, LocaleText>>,
355
+ matrix: Map<string, Map<string, MatrixShots>>,
356
+ ): {
357
+ chapters: Map<string, Chapter>;
358
+ sourceOrder: Map<string, number>;
359
+ configGroupOf: Map<string, string | undefined>;
360
+ } {
361
+ const chapters = new Map<string, Chapter>();
362
+ const ensureChapter = (id: string): Chapter => {
363
+ const existing = chapters.get(id);
364
+ if (existing) return existing;
365
+ const created: Chapter = {
366
+ id,
367
+ groupId: FALLBACK_GROUP_ID,
368
+ texts: new Map(),
369
+ matrix: new Map(),
370
+ inScreenList: false,
371
+ };
372
+ chapters.set(id, created);
373
+ return created;
374
+ };
375
+
376
+ const sourceOrder = new Map<string, number>();
377
+ const configGroupOf = new Map<string, string | undefined>();
378
+ const allScreens = [...screens, ...flowChapters.map((entry) => entry.ref)];
379
+ allScreens.forEach((screen, index) => {
380
+ const chapter = ensureChapter(screen.id);
381
+ chapter.inScreenList = true;
382
+ chapter.screenTitle = screen.title;
383
+ sourceOrder.set(screen.id, index);
384
+ configGroupOf.set(screen.id, screen.group);
385
+ });
386
+ for (const [locale, texts] of textsByLocale) {
387
+ for (const [id, text] of texts) ensureChapter(id).texts.set(locale, text);
388
+ }
389
+ for (const [id, perLocale] of matrix) ensureChapter(id).matrix = perLocale;
390
+ for (const entry of flowChapters) {
391
+ const chapter = ensureChapter(entry.ref.id);
392
+ if (entry.shots) chapter.matrix = entry.shots;
393
+ if (entry.steps) chapter.steps = entry.steps;
394
+ }
395
+ return { chapters, sourceOrder, configGroupOf };
396
+ }
397
+
398
+ export async function buildModel(
399
+ config: HandbuchConfig,
400
+ options: BuildModelOptions,
401
+ ): Promise<Model> {
402
+ const repoDir = resolve(options.rootDir, config.repo);
403
+ const screens =
404
+ options.screens ??
405
+ config.screens ??
406
+ (config.screensModule
407
+ ? await loadScreensModule(options.rootDir, config.repo, config.screensModule)
408
+ : []);
409
+
410
+ const textsByLocale = new Map(
411
+ config.locales.map((locale) => [
412
+ locale,
413
+ readTexts(
414
+ options.textsDir
415
+ ? join(options.textsDir, locale)
416
+ : join(repoDir, config.textsDir.replace("{locale}", locale)),
417
+ ),
418
+ ]),
419
+ );
420
+ const applyStale = (
421
+ byLocale: Map<string, Map<string, LocaleText>>,
422
+ uiStrings: Map<string, string[]>,
423
+ ) => {
424
+ const uiIndex = buildUiIndex(uiStrings);
425
+ for (const [locale, texts] of byLocale) {
426
+ for (const text of texts.values()) {
427
+ text.staleTerms = findMissingUiTerms(text.bodyMd, locale, uiIndex, text.uiIgnore);
428
+ }
429
+ }
430
+ };
431
+ if (config.uiStrings || config.uiStringPackages) {
432
+ applyStale(
433
+ textsByLocale,
434
+ collectUiStrings({
435
+ repoDir,
436
+ locales: config.locales,
437
+ globs: config.uiStrings ?? [],
438
+ packages: config.uiStringPackages ?? [],
439
+ }),
440
+ );
441
+ }
442
+
443
+ const tr = config.translations?.[config.defaultLocale];
444
+ const labelGroup = (group: Group): Group => ({
445
+ ...group,
446
+ label: tr?.groups[group.id] ?? group.label,
447
+ });
448
+ const fallbackGroupDefault: Group = {
449
+ id: FALLBACK_GROUP_ID,
450
+ label: chromeFor(config.defaultLocale).fallbackGroup,
451
+ };
452
+
453
+ const { flowChapters, flowGroups } = collectFlowSources(
454
+ config,
455
+ options,
456
+ textsByLocale,
457
+ applyStale,
458
+ labelGroup,
459
+ );
460
+ const shotsDir =
461
+ options.shotsDir ?? (config.shotsDir ? resolve(options.rootDir, config.shotsDir) : undefined);
462
+ const matrix = shotsDir
463
+ ? discoverMatrix(shotsDir, config.locales)
464
+ : new Map<string, Map<string, MatrixShots>>();
465
+
466
+ const groups: Group[] = [...config.groups.map(labelGroup), ...flowGroups];
467
+ const { chapters, sourceOrder, configGroupOf } = registerChapters(
468
+ screens,
469
+ flowChapters,
470
+ textsByLocale,
471
+ matrix,
472
+ );
473
+
474
+ const fallbackGroup = assignGroups(config, chapters, groups, configGroupOf, fallbackGroupDefault);
475
+ return assembleModel(config, chapters, groups, sourceOrder, fallbackGroup);
476
+ }
477
+
478
+ function assignGroups(
479
+ config: HandbuchConfig,
480
+ chapters: Map<string, Chapter>,
481
+ groups: Group[],
482
+ configGroupOf: Map<string, string | undefined>,
483
+ fallbackGroupDefault: Group,
484
+ ): Group | undefined {
485
+ const findGroup = (key: string): Group | undefined =>
486
+ groups.find((group) => group.id === key || group.label.toLowerCase() === key.toLowerCase());
487
+ let fallbackGroup: Group | undefined;
488
+ for (const chapter of chapters.values()) {
489
+ const primary = chapter.texts.get(config.defaultLocale) ?? [...chapter.texts.values()][0];
490
+ const frontmatterKey = primary?.gruppe;
491
+ const configKey = configGroupOf.get(chapter.id);
492
+ let group: Group | undefined;
493
+ if (frontmatterKey) {
494
+ group = findGroup(frontmatterKey);
495
+ if (!group) {
496
+ group = {
497
+ id: slugify(frontmatterKey) || "gruppe",
498
+ label: frontmatterKey,
499
+ };
500
+ groups.push(group);
501
+ }
502
+ } else if (configKey) {
503
+ group = findGroup(configKey);
504
+ }
505
+ if (!group) {
506
+ fallbackGroup = findGroup(FALLBACK_GROUP_ID) ?? fallbackGroupDefault;
507
+ group = fallbackGroup;
508
+ }
509
+ chapter.groupId = group.id;
510
+ }
511
+ return fallbackGroup;
512
+ }
513
+
514
+ function assembleModel(
515
+ config: HandbuchConfig,
516
+ chapters: Map<string, Chapter>,
517
+ groups: Group[],
518
+ sourceOrder: Map<string, number>,
519
+ fallbackGroup: Group | undefined,
520
+ ): Model {
521
+ const collator = new Intl.Collator("de", { sensitivity: "base" });
522
+ const titleOf = (chapter: Chapter) =>
523
+ chapterTitle(chapter, config.defaultLocale, config.defaultLocale);
524
+ const compare = (a: Chapter, b: Chapter): number => {
525
+ if (config.sort === "title") return collator.compare(titleOf(a), titleOf(b));
526
+ const rankA = sourceOrder.get(a.id);
527
+ const rankB = sourceOrder.get(b.id);
528
+ if (rankA !== undefined && rankB !== undefined) return rankA - rankB;
529
+ if (rankA !== undefined) return -1;
530
+ if (rankB !== undefined) return 1;
531
+ return collator.compare(titleOf(a), titleOf(b));
532
+ };
533
+
534
+ const all = [...chapters.values()].sort(compare);
535
+ const planned = all.filter(isPlanned);
536
+ const documented = all.filter((chapter) => !isPlanned(chapter));
537
+
538
+ const orderedGroups =
539
+ fallbackGroup && !groups.includes(fallbackGroup) ? [...groups, fallbackGroup] : groups;
540
+ const withFallbackLast = [
541
+ ...orderedGroups.filter((group) => group.id !== FALLBACK_GROUP_ID),
542
+ ...orderedGroups.filter((group) => group.id === FALLBACK_GROUP_ID),
543
+ ];
544
+ const groupLabel = (id: string) => withFallbackLast.find((group) => group.id === id)?.label ?? id;
545
+
546
+ return {
547
+ locales: config.locales,
548
+ defaultLocale: config.defaultLocale,
549
+ groups: withFallbackLast
550
+ .map((group) => ({
551
+ ...group,
552
+ chapters: documented.filter((c) => c.groupId === group.id),
553
+ }))
554
+ .filter((group) => group.chapters.length > 0),
555
+ planned: planned.map((chapter) => ({
556
+ chapter,
557
+ groupLabel: groupLabel(chapter.groupId),
558
+ })),
559
+ stats: {
560
+ chapters: documented.length,
561
+ textMissing: documented.filter(isTextMissing).length,
562
+ imageMissing: documented.filter((chapter) => missingImagesOf(chapter).length > 0).length,
563
+ staleText: documented.filter((chapter) => staleTermsOf(chapter).length > 0).length,
564
+ planned: planned.length,
565
+ },
566
+ };
567
+ }
568
+
569
+ export function inlineImageDest(locale: string, textsRoot: string, absSource: string): string {
570
+ const relative = absSource.startsWith(`${textsRoot}/`)
571
+ ? absSource.slice(textsRoot.length + 1)
572
+ : `${basename(dirname(absSource))}/${basename(absSource)}`;
573
+ return `shots/inline/${locale}/${relative.replace(/[^\w.-]+/g, "__").replace(/\.[^.]+$/, "")}.webp`;
574
+ }