fconvert 0.1.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.
Files changed (46) hide show
  1. package/README.md +54 -0
  2. package/assets/manifest.json +15 -0
  3. package/bun.lock +26 -0
  4. package/dist/convert +0 -0
  5. package/package.json +25 -0
  6. package/src/artifacts/artifact.ts +21 -0
  7. package/src/artifacts/file.ts +11 -0
  8. package/src/bundle/manifest.ts +10 -0
  9. package/src/bundle/platform.ts +10 -0
  10. package/src/bundle/resolve.ts +88 -0
  11. package/src/cli/commands/convert.ts +105 -0
  12. package/src/cli/commands/doctor.ts +16 -0
  13. package/src/cli/commands/formats.ts +20 -0
  14. package/src/cli/commands/handlers.ts +37 -0
  15. package/src/cli/commands/route.ts +91 -0
  16. package/src/cli/main.ts +69 -0
  17. package/src/cli/parse.ts +127 -0
  18. package/src/core/config.ts +16 -0
  19. package/src/core/errors.ts +18 -0
  20. package/src/core/logger.ts +38 -0
  21. package/src/core/types.ts +77 -0
  22. package/src/diagnostics/doctor.ts +43 -0
  23. package/src/executor/executor.ts +177 -0
  24. package/src/executor/workspace.ts +47 -0
  25. package/src/formats/aliases.ts +8 -0
  26. package/src/formats/common.ts +69 -0
  27. package/src/formats/detect.ts +62 -0
  28. package/src/formats/registry.ts +64 -0
  29. package/src/handlers/base.ts +61 -0
  30. package/src/handlers/bridges/binary.ts +22 -0
  31. package/src/handlers/bridges/text.ts +127 -0
  32. package/src/handlers/exec.ts +47 -0
  33. package/src/handlers/native/ffmpeg.ts +77 -0
  34. package/src/handlers/native/imagemagick.ts +65 -0
  35. package/src/handlers/native/pandoc.ts +66 -0
  36. package/src/handlers/native/sevenzip.ts +115 -0
  37. package/src/handlers/registry.ts +68 -0
  38. package/src/planner/costs.ts +111 -0
  39. package/src/planner/deadends.ts +31 -0
  40. package/src/planner/explain.ts +12 -0
  41. package/src/planner/graph.ts +68 -0
  42. package/src/planner/search.ts +77 -0
  43. package/test/e2e/engine-bridge.test.ts +52 -0
  44. package/test/unit/detect.test.ts +15 -0
  45. package/test/unit/planner.test.ts +46 -0
  46. package/tsconfig.json +31 -0
@@ -0,0 +1,127 @@
1
+ import { CliError, ExitCode } from "../core/errors.ts";
2
+ import { DEFAULT_MAX_CANDIDATES, DEFAULT_MAX_STEPS } from "../core/config.ts";
3
+ import type { CliOptions } from "../core/types.ts";
4
+
5
+ export interface ParsedArgs {
6
+ command: "convert" | "route" | "formats" | "handlers" | "doctor";
7
+ positionals: string[];
8
+ options: CliOptions;
9
+ }
10
+
11
+ function parseNumber(value: string | undefined, fallback: number, flagName: string): number {
12
+ if (!value) {
13
+ return fallback;
14
+ }
15
+ const parsed = Number(value);
16
+ if (!Number.isFinite(parsed) || parsed <= 0) {
17
+ throw new CliError(`Invalid ${flagName} value: ${value}`, ExitCode.InvalidArgs);
18
+ }
19
+ return parsed;
20
+ }
21
+
22
+ export function parseArgs(argv: string[]): ParsedArgs {
23
+ const raw = [...argv];
24
+ const knownCommands = new Set(["convert", "route", "formats", "handlers", "doctor"]);
25
+
26
+ let command: ParsedArgs["command"] = "convert";
27
+ if (raw[0] && knownCommands.has(raw[0])) {
28
+ command = raw.shift() as ParsedArgs["command"];
29
+ }
30
+
31
+ const options: CliOptions = {
32
+ force: false,
33
+ strict: false,
34
+ json: false,
35
+ verbose: false,
36
+ quiet: false,
37
+ showRoute: false,
38
+ keepTemp: false,
39
+ maxSteps: DEFAULT_MAX_STEPS,
40
+ maxCandidates: DEFAULT_MAX_CANDIDATES,
41
+ };
42
+
43
+ const positionals: string[] = [];
44
+ for (let index = 0; index < raw.length; index += 1) {
45
+ const token = raw[index];
46
+ if (!token) {
47
+ continue;
48
+ }
49
+
50
+ const next = raw[index + 1];
51
+
52
+ if (token === "--from") {
53
+ if (!next) throw new CliError("Missing value for --from", ExitCode.InvalidArgs);
54
+ options.from = next;
55
+ index += 1;
56
+ continue;
57
+ }
58
+ if (token === "--to") {
59
+ if (!next) throw new CliError("Missing value for --to", ExitCode.InvalidArgs);
60
+ options.to = next;
61
+ index += 1;
62
+ continue;
63
+ }
64
+ if (token === "--output") {
65
+ if (!next) throw new CliError("Missing value for --output", ExitCode.InvalidArgs);
66
+ options.output = next;
67
+ index += 1;
68
+ continue;
69
+ }
70
+ if (token === "--timeout") {
71
+ options.timeoutMs = parseNumber(next, 0, "--timeout");
72
+ index += 1;
73
+ continue;
74
+ }
75
+ if (token === "--max-steps") {
76
+ options.maxSteps = parseNumber(next, DEFAULT_MAX_STEPS, "--max-steps");
77
+ index += 1;
78
+ continue;
79
+ }
80
+ if (token === "--max-candidates") {
81
+ options.maxCandidates = parseNumber(next, DEFAULT_MAX_CANDIDATES, "--max-candidates");
82
+ index += 1;
83
+ continue;
84
+ }
85
+
86
+ if (token === "--force") {
87
+ options.force = true;
88
+ continue;
89
+ }
90
+ if (token === "--strict") {
91
+ options.strict = true;
92
+ continue;
93
+ }
94
+ if (token === "--json") {
95
+ options.json = true;
96
+ continue;
97
+ }
98
+ if (token === "--verbose") {
99
+ options.verbose = true;
100
+ continue;
101
+ }
102
+ if (token === "--quiet") {
103
+ options.quiet = true;
104
+ continue;
105
+ }
106
+ if (token === "--show-route") {
107
+ options.showRoute = true;
108
+ continue;
109
+ }
110
+ if (token === "--keep-temp") {
111
+ options.keepTemp = true;
112
+ continue;
113
+ }
114
+
115
+ if (token.startsWith("--")) {
116
+ throw new CliError(`Unknown option: ${token}`, ExitCode.InvalidArgs);
117
+ }
118
+
119
+ positionals.push(token);
120
+ }
121
+
122
+ return {
123
+ command,
124
+ positionals,
125
+ options,
126
+ };
127
+ }
@@ -0,0 +1,16 @@
1
+ import type { PlanOptions } from "./types.ts";
2
+
3
+ export const DEFAULT_MAX_STEPS = 6;
4
+ export const DEFAULT_MAX_CANDIDATES = 12;
5
+
6
+ export function buildPlanOptions(options: {
7
+ strict: boolean;
8
+ maxSteps: number;
9
+ maxCandidates: number;
10
+ }): PlanOptions {
11
+ return {
12
+ strict: options.strict,
13
+ maxSteps: options.maxSteps,
14
+ maxCandidates: options.maxCandidates,
15
+ };
16
+ }
@@ -0,0 +1,18 @@
1
+ export enum ExitCode {
2
+ Ok = 0,
3
+ ConversionFailed = 1,
4
+ InvalidArgs = 2,
5
+ UnsupportedRoute = 3,
6
+ EnvironmentError = 4,
7
+ InternalError = 5,
8
+ }
9
+
10
+ export class CliError extends Error {
11
+ readonly exitCode: ExitCode;
12
+
13
+ constructor(message: string, exitCode: ExitCode) {
14
+ super(message);
15
+ this.name = "CliError";
16
+ this.exitCode = exitCode;
17
+ }
18
+ }
@@ -0,0 +1,38 @@
1
+ export interface Logger {
2
+ info(message: string): void;
3
+ warn(message: string): void;
4
+ error(message: string): void;
5
+ debug(message: string): void;
6
+ }
7
+
8
+ export class ConsoleLogger implements Logger {
9
+ private readonly verbose: boolean;
10
+ private readonly quiet: boolean;
11
+
12
+ constructor(verbose: boolean, quiet: boolean) {
13
+ this.verbose = verbose;
14
+ this.quiet = quiet;
15
+ }
16
+
17
+ info(message: string): void {
18
+ if (!this.quiet) {
19
+ console.log(message);
20
+ }
21
+ }
22
+
23
+ warn(message: string): void {
24
+ if (!this.quiet) {
25
+ console.warn(message);
26
+ }
27
+ }
28
+
29
+ error(message: string): void {
30
+ console.error(message);
31
+ }
32
+
33
+ debug(message: string): void {
34
+ if (this.verbose && !this.quiet) {
35
+ console.log(message);
36
+ }
37
+ }
38
+ }
@@ -0,0 +1,77 @@
1
+ export type Category =
2
+ | "data"
3
+ | "image"
4
+ | "video"
5
+ | "vector"
6
+ | "document"
7
+ | "text"
8
+ | "audio"
9
+ | "archive"
10
+ | "spreadsheet"
11
+ | "presentation"
12
+ | "font"
13
+ | "code"
14
+ | "binary";
15
+
16
+ export interface FormatDefinition {
17
+ id: string;
18
+ name: string;
19
+ extension: string;
20
+ extensions: string[];
21
+ mime: string[];
22
+ category: Category[];
23
+ aliases?: string[];
24
+ }
25
+
26
+ export interface FileFormat {
27
+ id: string;
28
+ name: string;
29
+ extension: string;
30
+ mime: string;
31
+ category: Category[];
32
+ aliases: string[];
33
+ }
34
+
35
+ export interface PlanOptions {
36
+ maxCandidates: number;
37
+ maxSteps: number;
38
+ strict: boolean;
39
+ }
40
+
41
+ export interface CliOptions {
42
+ from?: string;
43
+ to?: string;
44
+ output?: string;
45
+ force: boolean;
46
+ strict: boolean;
47
+ json: boolean;
48
+ verbose: boolean;
49
+ quiet: boolean;
50
+ showRoute: boolean;
51
+ keepTemp: boolean;
52
+ timeoutMs?: number;
53
+ maxSteps: number;
54
+ maxCandidates: number;
55
+ }
56
+
57
+ export interface DoctorCheck {
58
+ name: string;
59
+ ok: boolean;
60
+ detail: string;
61
+ }
62
+
63
+ export interface CommandResult {
64
+ ok: boolean;
65
+ message?: string;
66
+ data?: unknown;
67
+ warnings?: string[];
68
+ }
69
+
70
+ export interface ConvertSummary {
71
+ ok: boolean;
72
+ input: string;
73
+ output: string;
74
+ route: string[];
75
+ durationMs: number;
76
+ warnings: string[];
77
+ }
@@ -0,0 +1,43 @@
1
+ import { access } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { BundleResolver } from "../bundle/resolve.ts";
4
+ import type { DoctorCheck } from "../core/types.ts";
5
+
6
+ async function checkBinary(bundle: BundleResolver, name: string): Promise<DoctorCheck> {
7
+ const resolved = await bundle.resolveBinary(name);
8
+ if (!resolved) {
9
+ return {
10
+ name,
11
+ ok: false,
12
+ detail: "missing",
13
+ };
14
+ }
15
+ return {
16
+ name,
17
+ ok: true,
18
+ detail: resolved,
19
+ };
20
+ }
21
+
22
+ export async function runDoctor(bundle: BundleResolver): Promise<DoctorCheck[]> {
23
+ const checks: DoctorCheck[] = [];
24
+
25
+ const manifestPath = join(bundle.assetsDir, "manifest.json");
26
+ try {
27
+ await access(manifestPath);
28
+ checks.push({ name: "bundle-manifest", ok: true, detail: manifestPath });
29
+ } catch {
30
+ checks.push({ name: "bundle-manifest", ok: false, detail: `not found at ${manifestPath}` });
31
+ }
32
+
33
+ checks.push(await checkBinary(bundle, "ffmpeg"));
34
+ checks.push(await checkBinary(bundle, "pandoc"));
35
+ checks.push(await checkBinary(bundle, "magick"));
36
+
37
+ const sevenZip = (await checkBinary(bundle, "7zz")).ok
38
+ ? await checkBinary(bundle, "7zz")
39
+ : await checkBinary(bundle, "7z");
40
+ checks.push({ name: "7zip", ok: sevenZip.ok, detail: sevenZip.detail });
41
+
42
+ return checks;
43
+ }
@@ -0,0 +1,177 @@
1
+ import { copyFile, mkdir, stat } from "node:fs/promises";
2
+ import { dirname, extname, join } from "node:path";
3
+ import type { ArtifactRef } from "../artifacts/artifact.ts";
4
+ import type { BundleResolver } from "../bundle/resolve.ts";
5
+ import { CliError, ExitCode } from "../core/errors.ts";
6
+ import type { Logger } from "../core/logger.ts";
7
+ import type { FileFormat, PlanOptions } from "../core/types.ts";
8
+ import { FormatRegistry } from "../formats/registry.ts";
9
+ import type { HandlerContext, PlannedRoute } from "../handlers/base.ts";
10
+ import { HandlerRegistry } from "../handlers/registry.ts";
11
+ import { DeadEndTracker } from "../planner/deadends.ts";
12
+ import { ConversionGraph } from "../planner/graph.ts";
13
+ import { findRoutes } from "../planner/search.ts";
14
+ import { Workspace } from "./workspace.ts";
15
+
16
+ export interface EngineInput {
17
+ inputPath: string;
18
+ outputPath: string;
19
+ inputFormat: FileFormat;
20
+ outputFormat: FileFormat;
21
+ strict: boolean;
22
+ keepTemp: boolean;
23
+ timeoutMs?: number;
24
+ plan: PlanOptions;
25
+ }
26
+
27
+ export interface EngineResult {
28
+ route: PlannedRoute;
29
+ outputPath: string;
30
+ durationMs: number;
31
+ warnings: string[];
32
+ }
33
+
34
+ export class ConversionEngine {
35
+ constructor(
36
+ private readonly formats: FormatRegistry,
37
+ private readonly handlers: HandlerRegistry,
38
+ private readonly bundle: BundleResolver,
39
+ private readonly logger: Logger,
40
+ ) {}
41
+
42
+ private async validateInput(path: string): Promise<void> {
43
+ try {
44
+ const fileStat = await stat(path);
45
+ if (!fileStat.isFile()) {
46
+ throw new CliError(`Input is not a file: ${path}`, ExitCode.InvalidArgs);
47
+ }
48
+ } catch (error) {
49
+ if (error instanceof CliError) {
50
+ throw error;
51
+ }
52
+ throw new CliError(`Input file not found: ${path}`, ExitCode.InvalidArgs);
53
+ }
54
+ }
55
+
56
+ async planRoutes(
57
+ inputFormat: FileFormat,
58
+ outputFormat: FileFormat,
59
+ strict: boolean,
60
+ plan: PlanOptions,
61
+ timeoutMs?: number,
62
+ ): Promise<{ workspace: Workspace; routes: PlannedRoute[] }> {
63
+ const workspace = await Workspace.create();
64
+ await this.handlers.init({
65
+ workspace,
66
+ bundle: this.bundle,
67
+ logger: this.logger,
68
+ timeoutMs,
69
+ });
70
+
71
+ const graph = new ConversionGraph(this.formats, this.handlers.availableHandlers(), strict);
72
+ const routes = findRoutes(graph, inputFormat.id, outputFormat.id, plan);
73
+ return { workspace, routes };
74
+ }
75
+
76
+ async execute(input: EngineInput): Promise<EngineResult> {
77
+ const startedAt = Date.now();
78
+ await this.validateInput(input.inputPath);
79
+
80
+ const { workspace, routes } = await this.planRoutes(
81
+ input.inputFormat,
82
+ input.outputFormat,
83
+ input.strict,
84
+ input.plan,
85
+ input.timeoutMs,
86
+ );
87
+
88
+ const deadEnds = new DeadEndTracker();
89
+ const warnings: string[] = [];
90
+
91
+ try {
92
+ if (routes.length === 0) {
93
+ throw new CliError(
94
+ `No route found from ${input.inputFormat.id} to ${input.outputFormat.id}`,
95
+ ExitCode.UnsupportedRoute,
96
+ );
97
+ }
98
+
99
+ for (const route of routes) {
100
+ if (deadEnds.routeBlocked(route)) {
101
+ continue;
102
+ }
103
+
104
+ const routeAttempt = await this.tryRoute(route, input, workspace, warnings);
105
+ if (routeAttempt.failedPrefix) {
106
+ deadEnds.markDeadPrefix(routeAttempt.failedPrefix);
107
+ continue;
108
+ }
109
+
110
+ if (routeAttempt.artifact) {
111
+ await workspace.cleanup(input.keepTemp);
112
+ return {
113
+ route,
114
+ outputPath: routeAttempt.artifact.path,
115
+ durationMs: Date.now() - startedAt,
116
+ warnings,
117
+ };
118
+ }
119
+ }
120
+
121
+ throw new CliError("All candidate routes failed", ExitCode.ConversionFailed);
122
+ } catch (error) {
123
+ await workspace.cleanup(input.keepTemp);
124
+ throw error;
125
+ }
126
+
127
+ }
128
+
129
+ private async tryRoute(
130
+ route: PlannedRoute,
131
+ input: EngineInput,
132
+ workspace: Workspace,
133
+ warnings: string[],
134
+ ): Promise<{ artifact?: ArtifactRef; failedPrefix?: PlannedRoute["edges"] }> {
135
+ const inputCopy = join(workspace.inputDir, `source${extname(input.inputPath)}`);
136
+ await copyFile(input.inputPath, inputCopy);
137
+ let currentArtifact: ArtifactRef = { kind: "file", path: inputCopy };
138
+
139
+ const context: HandlerContext = {
140
+ workspace,
141
+ bundle: this.bundle,
142
+ logger: this.logger,
143
+ timeoutMs: input.timeoutMs,
144
+ };
145
+
146
+ for (let index = 0; index < route.edges.length; index += 1) {
147
+ const edge = route.edges[index];
148
+ if (!edge) {
149
+ return { failedPrefix: route.edges.slice(0, index) };
150
+ }
151
+
152
+ const stepDir = await workspace.ensureStepDir(index);
153
+ const outputPath = join(stepDir, `output.${edge.to.extension}`);
154
+ const prefix = route.edges.slice(0, index + 1);
155
+
156
+ try {
157
+ const result = await edge.handler.convert(context, {
158
+ input: currentArtifact,
159
+ outputPath,
160
+ inputFormat: edge.from,
161
+ outputFormat: edge.to,
162
+ });
163
+ currentArtifact = result.output;
164
+ if (result.warnings && result.warnings.length > 0) {
165
+ warnings.push(...result.warnings);
166
+ }
167
+ } catch (error) {
168
+ this.logger.debug(`Route failed at step ${index + 1}: ${(error as Error).message}`);
169
+ return { failedPrefix: prefix };
170
+ }
171
+ }
172
+
173
+ await mkdir(dirname(input.outputPath), { recursive: true });
174
+ await copyFile(currentArtifact.path, input.outputPath);
175
+ return { artifact: { kind: "file", path: input.outputPath } };
176
+ }
177
+ }
@@ -0,0 +1,47 @@
1
+ import { mkdtemp, mkdir, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ export class Workspace {
6
+ readonly root: string;
7
+ readonly inputDir: string;
8
+ readonly stepsDir: string;
9
+ readonly outputDir: string;
10
+ readonly logsDir: string;
11
+
12
+ private constructor(root: string) {
13
+ this.root = root;
14
+ this.inputDir = join(root, "input");
15
+ this.stepsDir = join(root, "steps");
16
+ this.outputDir = join(root, "output");
17
+ this.logsDir = join(root, "logs");
18
+ }
19
+
20
+ static async create(prefix = "convert-"): Promise<Workspace> {
21
+ const root = await mkdtemp(join(tmpdir(), prefix));
22
+ const workspace = new Workspace(root);
23
+ await mkdir(workspace.inputDir, { recursive: true });
24
+ await mkdir(workspace.stepsDir, { recursive: true });
25
+ await mkdir(workspace.outputDir, { recursive: true });
26
+ await mkdir(workspace.logsDir, { recursive: true });
27
+ return workspace;
28
+ }
29
+
30
+ stepDir(index: number): string {
31
+ const normalized = String(index + 1).padStart(2, "0");
32
+ return join(this.stepsDir, normalized);
33
+ }
34
+
35
+ async ensureStepDir(index: number): Promise<string> {
36
+ const path = this.stepDir(index);
37
+ await mkdir(path, { recursive: true });
38
+ return path;
39
+ }
40
+
41
+ async cleanup(keepTemp: boolean): Promise<void> {
42
+ if (keepTemp) {
43
+ return;
44
+ }
45
+ await rm(this.root, { recursive: true, force: true });
46
+ }
47
+ }
@@ -0,0 +1,8 @@
1
+ export const FORMAT_ALIASES: Record<string, string> = {
2
+ jpg: "jpeg",
3
+ jpeg: "jpeg",
4
+ markdown: "md",
5
+ yml: "yaml",
6
+ text: "txt",
7
+ binary: "bin",
8
+ };
@@ -0,0 +1,69 @@
1
+ import type { Category, FormatDefinition } from "../core/types.ts";
2
+
3
+ function defineFormat(
4
+ id: string,
5
+ name: string,
6
+ extension: string,
7
+ mime: string[],
8
+ category: Category[],
9
+ aliases: string[] = [],
10
+ ): FormatDefinition {
11
+ return {
12
+ id,
13
+ name,
14
+ extension,
15
+ extensions: [extension],
16
+ mime,
17
+ category,
18
+ aliases,
19
+ };
20
+ }
21
+
22
+ export const COMMON_FORMATS: FormatDefinition[] = [
23
+ defineFormat("png", "Portable Network Graphics", "png", ["image/png"], ["image"]),
24
+ defineFormat("jpeg", "JPEG Image", "jpg", ["image/jpeg"], ["image"], ["jpg"]),
25
+ defineFormat("webp", "WebP Image", "webp", ["image/webp"], ["image"]),
26
+ defineFormat("gif", "Graphics Interchange Format", "gif", ["image/gif"], ["image", "video"]),
27
+ defineFormat("bmp", "Bitmap Image", "bmp", ["image/bmp"], ["image"]),
28
+ defineFormat("tiff", "Tagged Image File Format", "tiff", ["image/tiff"], ["image"], ["tif"]),
29
+ defineFormat("svg", "Scalable Vector Graphics", "svg", ["image/svg+xml"], ["image", "vector", "document"]),
30
+
31
+ defineFormat("wav", "Waveform Audio", "wav", ["audio/wav"], ["audio"]),
32
+ defineFormat("mp3", "MP3 Audio", "mp3", ["audio/mpeg"], ["audio"]),
33
+ defineFormat("flac", "FLAC Audio", "flac", ["audio/flac"], ["audio"]),
34
+ defineFormat("ogg", "Ogg Audio", "ogg", ["audio/ogg"], ["audio"]),
35
+
36
+ defineFormat("mp4", "MPEG-4 Video", "mp4", ["video/mp4"], ["video"]),
37
+ defineFormat("mov", "QuickTime MOV", "mov", ["video/quicktime"], ["video"]),
38
+ defineFormat("webm", "WebM Video", "webm", ["video/webm"], ["video"]),
39
+ defineFormat("wmv", "Windows Media Video", "wmv", ["video/x-ms-wmv"], ["video"]),
40
+
41
+ defineFormat("txt", "Plain Text", "txt", ["text/plain"], ["text"]),
42
+ defineFormat("md", "Markdown", "md", ["text/markdown"], ["text", "document"], ["markdown"]),
43
+ defineFormat("html", "HyperText Markup Language", "html", ["text/html"], ["text", "document"]),
44
+ defineFormat("json", "JSON", "json", ["application/json"], ["data", "text"]),
45
+ defineFormat("xml", "XML", "xml", ["application/xml", "text/xml"], ["data", "text"]),
46
+ defineFormat("yaml", "YAML", "yml", ["application/yaml", "text/yaml"], ["data", "text"], ["yml"]),
47
+ defineFormat("csv", "Comma Separated Values", "csv", ["text/csv"], ["data", "text"]),
48
+ defineFormat("css", "Cascading Style Sheets", "css", ["text/css"], ["code", "text"]),
49
+ defineFormat("sh", "Shell Script", "sh", ["application/x-sh"], ["code", "text"]),
50
+ defineFormat("py", "Python Script", "py", ["text/x-python"], ["code", "text"]),
51
+
52
+ defineFormat("pdf", "Portable Document Format", "pdf", ["application/pdf"], ["document"]),
53
+ defineFormat("docx", "Word Document", "docx", ["application/vnd.openxmlformats-officedocument.wordprocessingml.document"], ["document"]),
54
+ defineFormat("xlsx", "Excel Workbook", "xlsx", ["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"], ["spreadsheet", "document"]),
55
+ defineFormat("pptx", "PowerPoint Presentation", "pptx", ["application/vnd.openxmlformats-officedocument.presentationml.presentation"], ["presentation", "document"]),
56
+
57
+ defineFormat("zip", "ZIP Archive", "zip", ["application/zip"], ["archive"]),
58
+ defineFormat("tar", "TAR Archive", "tar", ["application/x-tar"], ["archive"]),
59
+ defineFormat("7z", "7-Zip Archive", "7z", ["application/x-7z-compressed"], ["archive"]),
60
+
61
+ defineFormat("ttf", "TrueType Font", "ttf", ["font/ttf"], ["font"]),
62
+ defineFormat("otf", "OpenType Font", "otf", ["font/otf"], ["font"]),
63
+ defineFormat("woff", "Web Open Font Format", "woff", ["font/woff"], ["font"]),
64
+ defineFormat("woff2", "Web Open Font Format 2", "woff2", ["font/woff2"], ["font"]),
65
+
66
+ defineFormat("base64", "Base64 Text", "b64", ["text/plain"], ["data", "text"]),
67
+ defineFormat("hex", "Hex Text", "hex", ["text/plain"], ["data", "text"]),
68
+ defineFormat("bin", "Binary Blob", "bin", ["application/octet-stream"], ["binary"]),
69
+ ];
@@ -0,0 +1,62 @@
1
+ import { extname } from "node:path";
2
+ import { CliError, ExitCode } from "../core/errors.ts";
3
+ import type { FileFormat } from "../core/types.ts";
4
+ import { FormatRegistry } from "./registry.ts";
5
+
6
+ export function detectInputFormat(
7
+ inputPath: string,
8
+ override: string | undefined,
9
+ registry: FormatRegistry,
10
+ ): FileFormat {
11
+ if (override) {
12
+ const format = registry.getById(override);
13
+ if (!format) {
14
+ throw new CliError(`Unknown input format: ${override}`, ExitCode.InvalidArgs);
15
+ }
16
+ return format;
17
+ }
18
+
19
+ const extension = extname(inputPath).replace(/^\./, "").toLowerCase();
20
+ if (extension) {
21
+ const byExtension = registry.getByExtension(extension);
22
+ if (byExtension) {
23
+ return byExtension;
24
+ }
25
+ }
26
+
27
+ const bin = registry.getById("bin");
28
+ if (!bin) {
29
+ throw new CliError("Binary fallback format is missing", ExitCode.InternalError);
30
+ }
31
+ return bin;
32
+ }
33
+
34
+ export function resolveOutputFormat(
35
+ outputPath: string | undefined,
36
+ override: string | undefined,
37
+ registry: FormatRegistry,
38
+ ): FileFormat {
39
+ if (override) {
40
+ const format = registry.getById(override);
41
+ if (!format) {
42
+ throw new CliError(`Unknown output format: ${override}`, ExitCode.InvalidArgs);
43
+ }
44
+ return format;
45
+ }
46
+
47
+ if (!outputPath) {
48
+ throw new CliError("Missing output path or --to format", ExitCode.InvalidArgs);
49
+ }
50
+
51
+ const extension = extname(outputPath).replace(/^\./, "").toLowerCase();
52
+ if (!extension) {
53
+ throw new CliError("Could not infer output format from path; use --to", ExitCode.InvalidArgs);
54
+ }
55
+
56
+ const byExtension = registry.getByExtension(extension);
57
+ if (!byExtension) {
58
+ throw new CliError(`Unsupported output extension: .${extension}`, ExitCode.UnsupportedRoute);
59
+ }
60
+
61
+ return byExtension;
62
+ }