@cosmicdrift/kumiko-cli 0.355.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
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "CLI for Kumiko apps. `bunx @cosmicdrift/kumiko-cli new app <name>` scaffolds a runnable workspace; `add feature <name>` mounts a feature; `check` runs boot validation plus the guard suites with the step list derived from kumiko.json; `agent lint`, `project` and `consumer` operate on an existing app.",
|
|
3
|
+
"version": "0.357.0",
|
|
4
|
+
"description": "CLI for Kumiko apps. `bunx @cosmicdrift/kumiko-cli new app <name>` scaffolds a runnable workspace; `add feature <name>` mounts a feature; `check` runs boot validation plus the guard suites with the step list derived from kumiko.json; `agent lint`, `project` and `consumer` operate on an existing app; `handbuch build` renders the HTML handbuch.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
7
7
|
"repository": {
|
|
@@ -36,27 +36,38 @@
|
|
|
36
36
|
}
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@cosmicdrift/kumiko-dev-server": "0.
|
|
40
|
-
"@cosmicdrift/kumiko-guards": "0.
|
|
41
|
-
"@cosmicdrift/kumiko-repo-manifest": "0.
|
|
42
|
-
"@cosmicdrift/kumiko-testing": "0.
|
|
39
|
+
"@cosmicdrift/kumiko-dev-server": "0.357.0",
|
|
40
|
+
"@cosmicdrift/kumiko-guards": "0.357.0",
|
|
41
|
+
"@cosmicdrift/kumiko-repo-manifest": "0.357.0",
|
|
42
|
+
"@cosmicdrift/kumiko-testing": "0.357.0",
|
|
43
|
+
"marked": "^18.0.3",
|
|
43
44
|
"zod": "^4.4.3"
|
|
44
45
|
},
|
|
45
46
|
"peerDependencies": {
|
|
47
|
+
"sharp": "^0.35.5",
|
|
46
48
|
"@cosmicdrift/kumiko-bundled-features": ">=0.353.0 <1.0.0",
|
|
47
|
-
"@cosmicdrift/kumiko-framework": ">=0.353.0 <1.0.0"
|
|
49
|
+
"@cosmicdrift/kumiko-framework": ">=0.353.0 <1.0.0",
|
|
50
|
+
"@cosmicdrift/kumiko-headless": ">=0.353.0 <1.0.0"
|
|
48
51
|
},
|
|
49
52
|
"peerDependenciesMeta": {
|
|
53
|
+
"sharp": {
|
|
54
|
+
"optional": true
|
|
55
|
+
},
|
|
50
56
|
"@cosmicdrift/kumiko-bundled-features": {
|
|
51
57
|
"optional": true
|
|
52
58
|
},
|
|
53
59
|
"@cosmicdrift/kumiko-framework": {
|
|
54
60
|
"optional": true
|
|
61
|
+
},
|
|
62
|
+
"@cosmicdrift/kumiko-headless": {
|
|
63
|
+
"optional": true
|
|
55
64
|
}
|
|
56
65
|
},
|
|
57
66
|
"devDependencies": {
|
|
58
|
-
"@cosmicdrift/kumiko-bundled-features": "0.
|
|
59
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
67
|
+
"@cosmicdrift/kumiko-bundled-features": "0.357.0",
|
|
68
|
+
"@cosmicdrift/kumiko-framework": "0.357.0",
|
|
69
|
+
"@cosmicdrift/kumiko-headless": "0.357.0",
|
|
70
|
+
"sharp": "^0.35.5"
|
|
60
71
|
},
|
|
61
72
|
"publishConfig": {
|
|
62
73
|
"registry": "https://registry.npmjs.org",
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.357.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "kumiko handbuch build generates the user manual from a JSON config",
|
|
6
|
+
"detail": "New command `kumiko handbuch build` generates the user manual as a static page from a JSON config\n`kumiko handbuch build <config.json> [--root <dir>] [--out <dir>] [--shots <dir>] [--texts <dir>] [--web-shots <dir>] [--web-texts <dir>] [--web-repo <dir>] [--repo <dir>] [--locale <l>] [--no-large]`. Relative config paths resolve against `--root` (default: the working directory); the default output is `<root>/.handbuch/<config-name>`. Image conversion needs `sharp`, which is an optional peer dependency: without it the command prints an install hint and exits 1. The command also needs the app's `@cosmicdrift/kumiko-framework` and `@cosmicdrift/kumiko-headless` (optional peers, loaded only when `handbuch build` runs). Replaces the workspace handbuch builder script."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.354.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { basename, join, resolve } from "node:path";
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { ZodError } from "zod";
|
|
4
|
+
import type { HandbuchConfig } from "../handbuch/config";
|
|
5
|
+
import type { CliCommand, CliCommandContext } from "./types";
|
|
6
|
+
|
|
7
|
+
const USAGE =
|
|
8
|
+
"Usage: kumiko handbuch build <config.json> [--root <dir>] [--out <dir>] [--shots <dir>] [--texts <dir>] [--web-shots <dir>] [--web-texts <dir>] [--web-repo <dir>] [--repo <dir>] [--locale <l>] [--no-large]";
|
|
9
|
+
|
|
10
|
+
const BUILD_OPTIONS = {
|
|
11
|
+
root: { type: "string" },
|
|
12
|
+
out: { type: "string" },
|
|
13
|
+
shots: { type: "string" },
|
|
14
|
+
texts: { type: "string" },
|
|
15
|
+
"web-shots": { type: "string" },
|
|
16
|
+
"web-texts": { type: "string" },
|
|
17
|
+
"web-repo": { type: "string" },
|
|
18
|
+
repo: { type: "string" },
|
|
19
|
+
locale: { type: "string" },
|
|
20
|
+
"no-large": { type: "boolean" },
|
|
21
|
+
} as const;
|
|
22
|
+
|
|
23
|
+
function usageError(ctx: CliCommandContext, message?: string): number {
|
|
24
|
+
ctx.out.err("");
|
|
25
|
+
if (message) ctx.out.err(` ${message}`);
|
|
26
|
+
ctx.out.err(` ${USAGE}`);
|
|
27
|
+
ctx.out.err("");
|
|
28
|
+
return 2;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function describeError(error: unknown): string {
|
|
32
|
+
if (error instanceof ZodError) {
|
|
33
|
+
return error.issues
|
|
34
|
+
.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
|
|
35
|
+
.join("\n ");
|
|
36
|
+
}
|
|
37
|
+
return error instanceof Error ? error.message : String(error);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function appSlug(configPath: string): string {
|
|
41
|
+
return basename(configPath).replace(/\.json$/, "") || "handbuch";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function runBuild(ctx: CliCommandContext, args: ReadonlyArray<string>): Promise<number> {
|
|
45
|
+
let parsed: ReturnType<
|
|
46
|
+
typeof parseArgs<{ options: typeof BUILD_OPTIONS; allowPositionals: true }>
|
|
47
|
+
>;
|
|
48
|
+
try {
|
|
49
|
+
parsed = parseArgs({ args: [...args], options: BUILD_OPTIONS, allowPositionals: true });
|
|
50
|
+
} catch (error) {
|
|
51
|
+
return usageError(ctx, describeError(error));
|
|
52
|
+
}
|
|
53
|
+
const { values, positionals } = parsed;
|
|
54
|
+
const configArg = positionals[0];
|
|
55
|
+
if (!configArg) return usageError(ctx, "Missing <config.json>.");
|
|
56
|
+
|
|
57
|
+
const rootDir = resolve(ctx.cwd, values.root ?? ".");
|
|
58
|
+
const configPath = resolve(rootDir, configArg);
|
|
59
|
+
// Lazy: the builder pulls in optional peers (framework, headless) that other commands must not need.
|
|
60
|
+
const { loadHandbuchConfig, restrictToLocale } = await import("../handbuch/config");
|
|
61
|
+
const { buildHandbuch, SHARP_MISSING_MESSAGE } = await import("../handbuch/build");
|
|
62
|
+
let config: HandbuchConfig;
|
|
63
|
+
try {
|
|
64
|
+
const loaded = loadHandbuchConfig(configPath);
|
|
65
|
+
const narrowed = values.locale ? restrictToLocale(loaded, values.locale) : loaded;
|
|
66
|
+
// Worktree checkouts hold the code the texts describe; the configured
|
|
67
|
+
// sibling checkouts may sit on another commit.
|
|
68
|
+
config = {
|
|
69
|
+
...narrowed,
|
|
70
|
+
repo: values.repo ? resolve(ctx.cwd, values.repo) : narrowed.repo,
|
|
71
|
+
flowSources: narrowed.flowSources?.map((source) => ({
|
|
72
|
+
...source,
|
|
73
|
+
repo: values["web-repo"] ? resolve(ctx.cwd, values["web-repo"]) : source.repo,
|
|
74
|
+
})),
|
|
75
|
+
};
|
|
76
|
+
} catch (error) {
|
|
77
|
+
ctx.out.err("");
|
|
78
|
+
ctx.out.err(` Invalid handbuch config ${configPath}:`);
|
|
79
|
+
ctx.out.err(` ${describeError(error)}`);
|
|
80
|
+
ctx.out.err(` ${USAGE}`);
|
|
81
|
+
ctx.out.err("");
|
|
82
|
+
return 2;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const optionalDir = (value: string | undefined) => (value ? resolve(ctx.cwd, value) : undefined);
|
|
86
|
+
try {
|
|
87
|
+
const result = await buildHandbuch(config, {
|
|
88
|
+
rootDir,
|
|
89
|
+
outDir: resolve(ctx.cwd, values.out ?? join(rootDir, ".handbuch", appSlug(configPath))),
|
|
90
|
+
shotsDir: optionalDir(values.shots),
|
|
91
|
+
textsDir: optionalDir(values.texts),
|
|
92
|
+
flowShotsDir: optionalDir(values["web-shots"]),
|
|
93
|
+
flowTextsDir: optionalDir(values["web-texts"]),
|
|
94
|
+
withLarge: !values["no-large"],
|
|
95
|
+
});
|
|
96
|
+
const { stats } = result;
|
|
97
|
+
ctx.out.log(
|
|
98
|
+
`${config.title}: ${stats.chapters} Kapitel, ${stats.textMissing} Text fehlt, ${stats.imageMissing} Bild fehlt, ${stats.staleText} Text veraltet?, ${stats.planned} geplant`,
|
|
99
|
+
);
|
|
100
|
+
ctx.out.log(
|
|
101
|
+
`${result.outDir} (${result.imageCount} Bilder, ${(result.totalBytes / 1024 / 1024).toFixed(1)} MB)`,
|
|
102
|
+
);
|
|
103
|
+
return 0;
|
|
104
|
+
} catch (error) {
|
|
105
|
+
ctx.out.err("");
|
|
106
|
+
ctx.out.err(
|
|
107
|
+
` ✗ ${error instanceof Error && error.message === SHARP_MISSING_MESSAGE ? error.message : describeError(error)}`,
|
|
108
|
+
);
|
|
109
|
+
ctx.out.err("");
|
|
110
|
+
return 1;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export const handbuchCommand: CliCommand = {
|
|
115
|
+
id: "handbuch",
|
|
116
|
+
description: "Build the HTML handbuch from a config (build <config.json>)",
|
|
117
|
+
help: `${USAGE}\n\nRenders the handbuch page from the texts and screenshots a config points at.\nRelative paths in the config resolve against --root (default: cwd); the output\ngoes to <root>/.handbuch/<config-name> unless --out is given. Needs sharp\n(bun add -d sharp) for the image conversion.`,
|
|
118
|
+
run: async (ctx) => {
|
|
119
|
+
const [sub, ...rest] = ctx.argv;
|
|
120
|
+
if (sub !== "build") return usageError(ctx);
|
|
121
|
+
return runBuild(ctx, rest);
|
|
122
|
+
},
|
|
123
|
+
};
|
package/src/commands/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { agentCommand } from "./agent";
|
|
2
2
|
import { checkCommand } from "./check";
|
|
3
3
|
import { consumerCommand } from "./consumer";
|
|
4
|
+
import { handbuchCommand } from "./handbuch";
|
|
4
5
|
import { projectCommand } from "./project";
|
|
5
6
|
import type { CliCommand } from "./types";
|
|
6
7
|
|
|
@@ -9,6 +10,7 @@ export const APP_COMMANDS: ReadonlyArray<CliCommand> = [
|
|
|
9
10
|
agentCommand,
|
|
10
11
|
projectCommand,
|
|
11
12
|
consumerCommand,
|
|
13
|
+
handbuchCommand,
|
|
12
14
|
];
|
|
13
15
|
|
|
14
16
|
export function findAppCommand(id: string): CliCommand | undefined {
|
|
@@ -23,4 +25,4 @@ export {
|
|
|
23
25
|
runCheck,
|
|
24
26
|
} from "./check";
|
|
25
27
|
export type { CliCommand, CliCommandContext } from "./types";
|
|
26
|
-
export { agentCommand, checkCommand, consumerCommand, projectCommand };
|
|
28
|
+
export { agentCommand, checkCommand, consumerCommand, handbuchCommand, projectCommand };
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { collectBuildInfo, type WallClock } from "./buildinfo";
|
|
4
|
+
import type { HandbuchConfig } from "./config";
|
|
5
|
+
import { buildModel, type Model } from "./model";
|
|
6
|
+
import { createImagePlan, type ImagePlan, renderPage } from "./render";
|
|
7
|
+
|
|
8
|
+
const CONVERSION_BATCH = 8;
|
|
9
|
+
|
|
10
|
+
export type BuildResult = {
|
|
11
|
+
outDir: string;
|
|
12
|
+
stats: Model["stats"];
|
|
13
|
+
imageCount: number;
|
|
14
|
+
totalBytes: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type Sharp = typeof import("sharp").default;
|
|
18
|
+
|
|
19
|
+
export const SHARP_MISSING_MESSAGE =
|
|
20
|
+
"sharp is not installed. Install it to build the handbuch (bun add -d sharp).";
|
|
21
|
+
|
|
22
|
+
// sharp is native and an optional peer: only the build path may load it.
|
|
23
|
+
async function loadSharp(): Promise<Sharp> {
|
|
24
|
+
try {
|
|
25
|
+
return (await import("sharp")).default;
|
|
26
|
+
} catch {
|
|
27
|
+
throw new Error(SHARP_MISSING_MESSAGE);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function convertImages(sharp: Sharp, outDir: string, images: ImagePlan): Promise<void> {
|
|
32
|
+
const jobs = [...images.jobs];
|
|
33
|
+
for (let start = 0; start < jobs.length; start += CONVERSION_BATCH) {
|
|
34
|
+
await Promise.all(
|
|
35
|
+
jobs.slice(start, start + CONVERSION_BATCH).map(async ([dest, job]) => {
|
|
36
|
+
const target = join(outDir, dest);
|
|
37
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
38
|
+
await sharp(job.src)
|
|
39
|
+
.flatten({ background: "#ffffff" })
|
|
40
|
+
.resize({ width: job.width, withoutEnlargement: true })
|
|
41
|
+
.webp({ quality: job.quality })
|
|
42
|
+
.toFile(target);
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function directorySize(dir: string): number {
|
|
49
|
+
let total = 0;
|
|
50
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
51
|
+
const path = join(dir, entry.name);
|
|
52
|
+
total += entry.isDirectory() ? directorySize(path) : statSync(path).size;
|
|
53
|
+
}
|
|
54
|
+
return total;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function buildHandbuch(
|
|
58
|
+
config: HandbuchConfig,
|
|
59
|
+
options: {
|
|
60
|
+
rootDir: string;
|
|
61
|
+
outDir: string;
|
|
62
|
+
shotsDir?: string;
|
|
63
|
+
textsDir?: string;
|
|
64
|
+
flowShotsDir?: string;
|
|
65
|
+
flowTextsDir?: string;
|
|
66
|
+
withLarge?: boolean;
|
|
67
|
+
now?: WallClock;
|
|
68
|
+
},
|
|
69
|
+
): Promise<BuildResult> {
|
|
70
|
+
const sharp = await loadSharp();
|
|
71
|
+
const model = await buildModel(config, {
|
|
72
|
+
rootDir: options.rootDir,
|
|
73
|
+
shotsDir: options.shotsDir,
|
|
74
|
+
textsDir: options.textsDir,
|
|
75
|
+
flowShotsDir: options.flowShotsDir,
|
|
76
|
+
flowTextsDir: options.flowTextsDir,
|
|
77
|
+
});
|
|
78
|
+
const images = createImagePlan(options.withLarge ?? true);
|
|
79
|
+
const html = renderPage(
|
|
80
|
+
config.translations?.[config.defaultLocale]?.title ?? config.title,
|
|
81
|
+
model,
|
|
82
|
+
images,
|
|
83
|
+
collectBuildInfo(
|
|
84
|
+
resolve(options.rootDir, config.repo),
|
|
85
|
+
options.now,
|
|
86
|
+
(config.flowSources ?? []).map((source) => resolve(options.rootDir, source.repo)),
|
|
87
|
+
config.defaultLocale,
|
|
88
|
+
),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
rmSync(options.outDir, { recursive: true, force: true });
|
|
92
|
+
mkdirSync(options.outDir, { recursive: true });
|
|
93
|
+
await convertImages(sharp, options.outDir, images);
|
|
94
|
+
writeFileSync(join(options.outDir, "index.html"), html);
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
outDir: options.outDir,
|
|
98
|
+
stats: model.stats,
|
|
99
|
+
imageCount: images.jobs.size,
|
|
100
|
+
totalBytes: directorySize(options.outDir),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
const FRAMEWORK_PACKAGE = "@cosmicdrift/kumiko-framework";
|
|
6
|
+
|
|
7
|
+
export type BuildInfo = {
|
|
8
|
+
appVersion?: string;
|
|
9
|
+
frameworkVersion?: string;
|
|
10
|
+
frameworkPin?: string;
|
|
11
|
+
git?: { sha: string; branch: string };
|
|
12
|
+
sourceVersions: Array<{ name: string; version: string }>;
|
|
13
|
+
builtAt: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function readJson(path: string): Record<string, unknown> | undefined {
|
|
17
|
+
if (!existsSync(path)) return undefined;
|
|
18
|
+
try {
|
|
19
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
20
|
+
return typeof parsed === "object" && parsed !== null
|
|
21
|
+
? Object.fromEntries(Object.entries(parsed))
|
|
22
|
+
: undefined;
|
|
23
|
+
} catch {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function stringField(record: Record<string, unknown> | undefined, key: string): string | undefined {
|
|
29
|
+
const value = record?.[key];
|
|
30
|
+
return typeof value === "string" ? value : undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function resolvedFrameworkVersion(repoDir: string): string | undefined {
|
|
34
|
+
try {
|
|
35
|
+
const manifest = Bun.resolveSync(`${FRAMEWORK_PACKAGE}/package.json`, repoDir);
|
|
36
|
+
return stringField(readJson(manifest), "version");
|
|
37
|
+
} catch {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function gitOutput(repoDir: string, args: string[]): string | undefined {
|
|
43
|
+
try {
|
|
44
|
+
const text = execFileSync("git", ["-C", repoDir, ...args], {
|
|
45
|
+
encoding: "utf8",
|
|
46
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
47
|
+
}).trim();
|
|
48
|
+
return text || undefined;
|
|
49
|
+
} catch {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type WallClock = { year: number; month: number; day: number; hour: number; minute: number };
|
|
55
|
+
|
|
56
|
+
export function currentWallClock(): WallClock {
|
|
57
|
+
const { year, month, day, hour, minute } = Temporal.Now.zonedDateTimeISO();
|
|
58
|
+
return { year, month, day, hour, minute };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const pad2 = (value: number) => String(value).padStart(2, "0");
|
|
62
|
+
|
|
63
|
+
export function formatGermanDateTime(at: WallClock): string {
|
|
64
|
+
return `${pad2(at.day)}.${pad2(at.month)}.${at.year}, ${pad2(at.hour)}:${pad2(at.minute)}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function formatIsoDateTime(at: WallClock): string {
|
|
68
|
+
return `${at.year}-${pad2(at.month)}-${pad2(at.day)} ${pad2(at.hour)}:${pad2(at.minute)}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function collectBuildInfo(
|
|
72
|
+
repoDir: string,
|
|
73
|
+
now: WallClock = currentWallClock(),
|
|
74
|
+
extraRepoDirs: readonly string[] = [],
|
|
75
|
+
locale = "de",
|
|
76
|
+
): BuildInfo {
|
|
77
|
+
const manifest = readJson(join(repoDir, "package.json"));
|
|
78
|
+
const dependencies = manifest?.["dependencies"];
|
|
79
|
+
const declared =
|
|
80
|
+
typeof dependencies === "object" && dependencies !== null
|
|
81
|
+
? Object.entries(dependencies).find(([name]) => name === FRAMEWORK_PACKAGE)?.[1]
|
|
82
|
+
: undefined;
|
|
83
|
+
const frameworkVersion = resolvedFrameworkVersion(repoDir);
|
|
84
|
+
const pin = typeof declared === "string" ? declared : undefined;
|
|
85
|
+
const sha = gitOutput(repoDir, ["rev-parse", "--short", "HEAD"]);
|
|
86
|
+
const branch = gitOutput(repoDir, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
87
|
+
return {
|
|
88
|
+
appVersion: stringField(manifest, "version"),
|
|
89
|
+
frameworkVersion,
|
|
90
|
+
frameworkPin: pin && pin !== frameworkVersion ? pin : undefined,
|
|
91
|
+
git: sha && branch ? { sha, branch } : undefined,
|
|
92
|
+
sourceVersions: extraRepoDirs.flatMap((dir) => {
|
|
93
|
+
const version = stringField(readJson(join(dir, "package.json")), "version");
|
|
94
|
+
return version ? [{ name: basename(dir), version }] : [];
|
|
95
|
+
}),
|
|
96
|
+
builtAt: locale === "de" ? formatGermanDateTime(now) : formatIsoDateTime(now),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function buildInfoLines(info: BuildInfo, builtLabel = "Stand"): string[] {
|
|
101
|
+
const lines: string[] = [];
|
|
102
|
+
if (info.appVersion) lines.push(`App ${info.appVersion}`);
|
|
103
|
+
if (info.frameworkVersion) {
|
|
104
|
+
lines.push(
|
|
105
|
+
`Kumiko ${info.frameworkVersion}${info.frameworkPin ? ` (Pin ${info.frameworkPin})` : ""}`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
if (info.git) lines.push(`${info.git.branch} @ ${info.git.sha}`);
|
|
109
|
+
for (const source of info.sourceVersions) lines.push(`${source.name} ${source.version}`);
|
|
110
|
+
lines.push(`${builtLabel} ${info.builtAt}`);
|
|
111
|
+
return lines;
|
|
112
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
export type Chrome = {
|
|
2
|
+
imageMissing: string;
|
|
3
|
+
textMissing: string;
|
|
4
|
+
textMissingBody: string;
|
|
5
|
+
textStale: string;
|
|
6
|
+
staleBody: string;
|
|
7
|
+
textFallbackNotice: (fallbackLocale: string) => string;
|
|
8
|
+
shotsFallbackNotice: (shotsLocale: string) => string;
|
|
9
|
+
chapters: string;
|
|
10
|
+
overview: string;
|
|
11
|
+
planned: string;
|
|
12
|
+
plannedShort: string;
|
|
13
|
+
staleShort: string;
|
|
14
|
+
plannedIntro: string;
|
|
15
|
+
plannedNone: string;
|
|
16
|
+
plannedColumns: [string, string, string];
|
|
17
|
+
appendix: string;
|
|
18
|
+
language: string;
|
|
19
|
+
size: string;
|
|
20
|
+
hint: string;
|
|
21
|
+
lightbox: string;
|
|
22
|
+
close: string;
|
|
23
|
+
fallbackGroup: string;
|
|
24
|
+
builtAt: string;
|
|
25
|
+
viewports: { desktop: string; tablet: string; mobile: string; all: string };
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const de: Chrome = {
|
|
29
|
+
imageMissing: "Bild fehlt",
|
|
30
|
+
textMissing: "Text fehlt",
|
|
31
|
+
textMissingBody: "Für dieses Kapitel gibt es noch keinen Text.",
|
|
32
|
+
textStale: "Text veraltet?",
|
|
33
|
+
staleBody: "Diese fett gesetzten Begriffe stehen nicht in den Oberflächentexten:",
|
|
34
|
+
textFallbackNotice: (locale) => `Text in dieser Sprache fehlt, angezeigt wird ${locale}.`,
|
|
35
|
+
shotsFallbackNotice: (locale) => `Screenshots nur in ${locale} vorhanden.`,
|
|
36
|
+
chapters: "Kapitel",
|
|
37
|
+
overview: "Übersicht",
|
|
38
|
+
planned: "Geplante Masken",
|
|
39
|
+
plannedShort: "geplant",
|
|
40
|
+
staleShort: "veraltet?",
|
|
41
|
+
plannedIntro: "Diese Masken stehen in der Screen-Liste, haben aber weder Text noch Screenshots.",
|
|
42
|
+
plannedNone: "Alle Masken sind dokumentiert.",
|
|
43
|
+
plannedColumns: ["Maske", "Kennung", "Gruppe"],
|
|
44
|
+
appendix: "Anhang",
|
|
45
|
+
language: "Sprache",
|
|
46
|
+
size: "Größe",
|
|
47
|
+
hint: "↑ ↓ Kapitel · 1 2 3 4 Größe",
|
|
48
|
+
lightbox: "Screenshot vergrößert",
|
|
49
|
+
close: "Schließen",
|
|
50
|
+
fallbackGroup: "Weitere",
|
|
51
|
+
builtAt: "Stand",
|
|
52
|
+
viewports: {
|
|
53
|
+
desktop: "Desktop",
|
|
54
|
+
tablet: "Tablet",
|
|
55
|
+
mobile: "Mobil",
|
|
56
|
+
all: "Alle",
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const en: Chrome = {
|
|
61
|
+
imageMissing: "Image missing",
|
|
62
|
+
textMissing: "Text missing",
|
|
63
|
+
textMissingBody: "This chapter has no text yet.",
|
|
64
|
+
textStale: "Text outdated?",
|
|
65
|
+
staleBody: "These bold terms do not appear in the UI strings:",
|
|
66
|
+
textFallbackNotice: (locale) => `Text missing in this language, showing ${locale}.`,
|
|
67
|
+
shotsFallbackNotice: (locale) => `Screenshots only available in ${locale}.`,
|
|
68
|
+
chapters: "Chapters",
|
|
69
|
+
overview: "Overview",
|
|
70
|
+
planned: "Planned screens",
|
|
71
|
+
plannedShort: "planned",
|
|
72
|
+
staleShort: "outdated?",
|
|
73
|
+
plannedIntro: "These screens are in the screen list but have neither text nor screenshots.",
|
|
74
|
+
plannedNone: "All screens are documented.",
|
|
75
|
+
plannedColumns: ["Screen", "ID", "Group"],
|
|
76
|
+
appendix: "Appendix",
|
|
77
|
+
language: "Language",
|
|
78
|
+
size: "Size",
|
|
79
|
+
hint: "↑ ↓ Chapter · 1 2 3 4 Size",
|
|
80
|
+
lightbox: "Screenshot enlarged",
|
|
81
|
+
close: "Close",
|
|
82
|
+
fallbackGroup: "Other",
|
|
83
|
+
builtAt: "Built",
|
|
84
|
+
viewports: {
|
|
85
|
+
desktop: "Desktop",
|
|
86
|
+
tablet: "Tablet",
|
|
87
|
+
mobile: "Mobile",
|
|
88
|
+
all: "All",
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const CHROME: Record<string, Chrome> = { de, en };
|
|
93
|
+
|
|
94
|
+
export function chromeFor(locale: string): Chrome {
|
|
95
|
+
return CHROME[locale] ?? de;
|
|
96
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { parseJsonOrThrow } from "@cosmicdrift/kumiko-framework/utils";
|
|
3
|
+
import * as z from "zod";
|
|
4
|
+
|
|
5
|
+
const screensModuleSchema = z.object({
|
|
6
|
+
path: z.string().min(1),
|
|
7
|
+
export: z.string().min(1),
|
|
8
|
+
idKey: z.string().min(1),
|
|
9
|
+
titleKey: z.string().min(1),
|
|
10
|
+
groupKey: z.string().min(1),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const inlineScreenSchema = z.object({
|
|
14
|
+
id: z.string().min(1),
|
|
15
|
+
title: z.string().optional(),
|
|
16
|
+
group: z.string().min(1),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const flowSourceSchema = z.object({
|
|
20
|
+
repo: z.string().min(1),
|
|
21
|
+
textsDir: z.string().includes("{locale}"),
|
|
22
|
+
pageFlows: z.array(z.string().min(1)).default([]),
|
|
23
|
+
pageGroup: z.object({ id: z.string().min(1), label: z.string().min(1) }),
|
|
24
|
+
flowGroup: z.object({ id: z.string().min(1), label: z.string().min(1) }),
|
|
25
|
+
flowTitles: z.record(z.string(), z.string()).default({}),
|
|
26
|
+
shotsDir: z.string().optional(),
|
|
27
|
+
uiStringsByLocale: z.record(z.string(), z.array(z.string().min(1))).optional(),
|
|
28
|
+
uiStringFiles: z.array(z.string().min(1)).optional(),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const groupSchema = z.object({
|
|
32
|
+
id: z.string().min(1),
|
|
33
|
+
label: z.string().min(1),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Labels for the build locale; ids without an entry keep their base label.
|
|
37
|
+
const translationsSchema = z.object({
|
|
38
|
+
title: z.string().min(1).optional(),
|
|
39
|
+
groups: z.record(z.string(), z.string()).default({}),
|
|
40
|
+
flows: z.record(z.string(), z.string()).default({}),
|
|
41
|
+
steps: z.record(z.string(), z.string()).default({}),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export const handbuchConfigSchema = z
|
|
45
|
+
.object({
|
|
46
|
+
title: z.string().min(1),
|
|
47
|
+
repo: z.string().min(1),
|
|
48
|
+
locales: z.array(z.string().min(1)).min(1),
|
|
49
|
+
defaultLocale: z.string().min(1),
|
|
50
|
+
groups: z.array(groupSchema).min(1),
|
|
51
|
+
translations: z.record(z.string(), translationsSchema).optional(),
|
|
52
|
+
textsDir: z.string().includes("{locale}"),
|
|
53
|
+
shotsDir: z.string().optional(),
|
|
54
|
+
uiStrings: z.array(z.string().min(1)).optional(),
|
|
55
|
+
uiStringPackages: z
|
|
56
|
+
.array(
|
|
57
|
+
z.object({
|
|
58
|
+
package: z.string().min(1),
|
|
59
|
+
files: z.array(z.string().min(1)).min(1),
|
|
60
|
+
locale: z.string().min(1).optional(),
|
|
61
|
+
}),
|
|
62
|
+
)
|
|
63
|
+
.optional(),
|
|
64
|
+
flowSources: z.array(flowSourceSchema).optional(),
|
|
65
|
+
sort: z.enum(["source", "title"]).default("source"),
|
|
66
|
+
screensModule: screensModuleSchema.optional(),
|
|
67
|
+
screens: z.array(inlineScreenSchema).optional(),
|
|
68
|
+
})
|
|
69
|
+
.refine((config) => config.locales.includes(config.defaultLocale), {
|
|
70
|
+
message: "defaultLocale must be one of locales",
|
|
71
|
+
path: ["defaultLocale"],
|
|
72
|
+
})
|
|
73
|
+
.refine((config) => !(config.screensModule && config.screens), {
|
|
74
|
+
message: "use either screensModule or screens, not both",
|
|
75
|
+
path: ["screens"],
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
export type HandbuchConfig = z.infer<typeof handbuchConfigSchema>;
|
|
79
|
+
export type FlowSourceConfig = z.infer<typeof flowSourceSchema>;
|
|
80
|
+
export type ScreensModuleConfig = z.infer<typeof screensModuleSchema>;
|
|
81
|
+
|
|
82
|
+
export function parseHandbuchConfig(raw: unknown): HandbuchConfig {
|
|
83
|
+
return handbuchConfigSchema.parse(raw);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function loadHandbuchConfig(configPath: string): HandbuchConfig {
|
|
87
|
+
const raw = parseJsonOrThrow<unknown>(readFileSync(configPath, "utf8"), configPath);
|
|
88
|
+
return parseHandbuchConfig(raw);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// One build per language keeps the artifact under its file limit.
|
|
92
|
+
export function restrictToLocale(config: HandbuchConfig, locale: string): HandbuchConfig {
|
|
93
|
+
if (!config.locales.includes(locale)) {
|
|
94
|
+
throw new Error(`--locale ${locale} is not one of ${config.locales.join(", ")}`);
|
|
95
|
+
}
|
|
96
|
+
return { ...config, locales: [locale], defaultLocale: locale };
|
|
97
|
+
}
|