docvars 0.3.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 (30) hide show
  1. package/README.md +209 -0
  2. package/dist/application/use-cases/dry-run.d.ts +12 -0
  3. package/dist/application/use-cases/dry-run.js +45 -0
  4. package/dist/application/use-cases/list-variables.d.ts +16 -0
  5. package/dist/application/use-cases/list-variables.js +48 -0
  6. package/dist/application/use-cases/process-templates.d.ts +2 -0
  7. package/dist/application/use-cases/process-templates.js +32 -0
  8. package/dist/application/use-cases/rename-variable.d.ts +13 -0
  9. package/dist/application/use-cases/rename-variable.js +80 -0
  10. package/dist/domain/entities/template.d.ts +5 -0
  11. package/dist/domain/entities/template.js +8 -0
  12. package/dist/domain/services/template-renderer.d.ts +3 -0
  13. package/dist/domain/services/template-renderer.js +15 -0
  14. package/dist/domain/value-objects/variables.d.ts +9 -0
  15. package/dist/domain/value-objects/variables.js +17 -0
  16. package/dist/infrastructure/repositories/template-repository.d.ts +3 -0
  17. package/dist/infrastructure/repositories/template-repository.js +11 -0
  18. package/dist/infrastructure/repositories/variables-repository.d.ts +2 -0
  19. package/dist/infrastructure/repositories/variables-repository.js +16 -0
  20. package/dist/infrastructure/services/file-scanner.d.ts +5 -0
  21. package/dist/infrastructure/services/file-scanner.js +8 -0
  22. package/dist/presentation/cli/commands/main.d.ts +48 -0
  23. package/dist/presentation/cli/commands/main.js +311 -0
  24. package/dist/presentation/cli/index.d.ts +2 -0
  25. package/dist/presentation/cli/index.js +4 -0
  26. package/dist/shared/errors.d.ts +9 -0
  27. package/dist/shared/errors.js +18 -0
  28. package/dist/shared/types.d.ts +15 -0
  29. package/dist/shared/types.js +1 -0
  30. package/package.json +58 -0
package/README.md ADDED
@@ -0,0 +1,209 @@
1
+ # docvars
2
+
3
+ A CLI tool to replace `{{variables}}` in document templates with values from a YAML file.
4
+
5
+ Supports any text-based files: Markdown, HTML, TXT, and more.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install -g docvars
11
+ ```
12
+
13
+ Or use with npx:
14
+
15
+ ```bash
16
+ npx docvars ./templates ./output
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ```bash
22
+ docvars <input> <output> [options]
23
+ ```
24
+
25
+ ### Arguments
26
+
27
+ | Argument | Description |
28
+ | -------- | ----------------------------------------- |
29
+ | `input` | Input directory containing template files |
30
+ | `output` | Output directory for processed files |
31
+
32
+ ### Options
33
+
34
+ | Option | Default | Description |
35
+ | --------------- | ---------------- | --------------------------------------------------- |
36
+ | `--vars` | `variables.yaml` | Path to the variables YAML file |
37
+ | `--include` | - | Glob pattern to include specific files |
38
+ | `--exclude` | - | Glob pattern to exclude specific files |
39
+ | `--watch` | `false` | Watch for file changes and rebuild automatically |
40
+ | `--rename-from` | - | Variable name to rename from (use with --rename-to) |
41
+ | `--rename-to` | - | Variable name to rename to (use with --rename-from) |
42
+ | `--list-vars` | `false` | List all variables used in templates |
43
+ | `--dry-run` | `false` | Preview changes without writing files |
44
+
45
+ ## Examples
46
+
47
+ ### Basic usage
48
+
49
+ ```bash
50
+ docvars ./templates ./output
51
+ ```
52
+
53
+ ### Custom variables file
54
+
55
+ ```bash
56
+ docvars ./templates ./output --vars production.yaml
57
+ ```
58
+
59
+ ### Filter files
60
+
61
+ ```bash
62
+ # Include only files matching pattern
63
+ docvars ./templates ./output --include "api-*.md"
64
+
65
+ # Exclude files matching pattern
66
+ docvars ./templates ./output --exclude "draft-*.md"
67
+ ```
68
+
69
+ ### Watch mode
70
+
71
+ ```bash
72
+ docvars ./templates ./output --watch
73
+ ```
74
+
75
+ This will watch for changes in:
76
+ - Template files in the input directory
77
+ - The variables YAML file
78
+
79
+ When changes are detected, templates are automatically rebuilt.
80
+
81
+ ### Rename variables
82
+
83
+ Rename a variable across all template files and the variables YAML file:
84
+
85
+ ```bash
86
+ # Simple rename
87
+ docvars ./templates ./output --rename-from "name" --rename-to "title"
88
+
89
+ # Rename nested variable
90
+ docvars ./templates ./output --rename-from "database.host" --rename-to "db.host"
91
+ ```
92
+
93
+ This updates:
94
+ - All `{{oldName}}` occurrences in template files → `{{newName}}`
95
+ - The key in the variables YAML file
96
+
97
+ ### List variables
98
+
99
+ Show all variables used in templates and their status:
100
+
101
+ ```bash
102
+ docvars ./templates ./output --list-vars
103
+ ```
104
+
105
+ Output:
106
+
107
+ ```
108
+ Variables used in templates:
109
+
110
+ app.name (✓)
111
+ → README.md
112
+ → config.md
113
+ api.key (✗ undefined)
114
+ → config.md
115
+
116
+ Unused variables (defined but not used):
117
+
118
+ deprecated.setting
119
+ ```
120
+
121
+ ### Dry run
122
+
123
+ Preview what files would be created or updated without actually writing them:
124
+
125
+ ```bash
126
+ docvars ./templates ./output --dry-run
127
+ ```
128
+
129
+ Output:
130
+
131
+ ```
132
+ Dry run - no files written
133
+
134
+ Files to create (1):
135
+ + config.md
136
+
137
+ Files to update (2):
138
+ ~ README.md
139
+ ~ api.md
140
+
141
+ Files unchanged (1):
142
+ = changelog.md
143
+
144
+ Summary: 1 create, 2 update, 1 unchanged
145
+ ```
146
+
147
+ ## Template Syntax
148
+
149
+ Use `{{variableName}}` syntax in your template files:
150
+
151
+ **Template (templates/hello.md):**
152
+ ```markdown
153
+ # Hello {{name}}
154
+
155
+ Welcome to {{project}}!
156
+ ```
157
+
158
+ **Variables (variables.yaml):**
159
+ ```yaml
160
+ name: World
161
+ project: My Project
162
+ ```
163
+
164
+ **Output (output/hello.md):**
165
+ ```markdown
166
+ # Hello World
167
+
168
+ Welcome to My Project!
169
+ ```
170
+
171
+ ### Nested Variables
172
+
173
+ You can use nested objects in your variables file and access them with dot notation:
174
+
175
+ **Template:**
176
+ ```markdown
177
+ # {{app.name}}
178
+
179
+ Database: {{database.host}}:{{database.port}}
180
+ ```
181
+
182
+ **Variables (variables.yaml):**
183
+ ```yaml
184
+ app:
185
+ name: My App
186
+
187
+ database:
188
+ host: localhost
189
+ port: 5432
190
+ ```
191
+
192
+ **Output:**
193
+ ```markdown
194
+ # My App
195
+
196
+ Database: localhost:5432
197
+ ```
198
+
199
+ ## Error Handling
200
+
201
+ | Case | Behavior |
202
+ | --------------------------- | --------------------------------------------------- |
203
+ | Undefined variable | Warning is displayed, variable syntax is kept as-is |
204
+ | Same input/output directory | Error and exit |
205
+ | Variables file not found | Error and exit |
206
+
207
+ ## License
208
+
209
+ MIT
@@ -0,0 +1,12 @@
1
+ import type { CliOptions } from "../../shared/types.js";
2
+ export interface FileChange {
3
+ relativePath: string;
4
+ outputPath: string;
5
+ status: "create" | "update" | "unchanged";
6
+ undefinedVariables: string[];
7
+ }
8
+ export interface DryRunResult {
9
+ changes: FileChange[];
10
+ warnings: string[];
11
+ }
12
+ export declare function dryRun(options: CliOptions): Promise<DryRunResult>;
@@ -0,0 +1,45 @@
1
+ import { resolve, relative, join } from "node:path";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { SameInputOutputError } from "../../shared/errors.js";
4
+ import { loadVariables } from "../../infrastructure/repositories/variables-repository.js";
5
+ import { readTemplate } from "../../infrastructure/repositories/template-repository.js";
6
+ import { scanTemplates } from "../../infrastructure/services/file-scanner.js";
7
+ import { renderTemplate } from "../../domain/services/template-renderer.js";
8
+ export async function dryRun(options) {
9
+ const inputDir = resolve(options.input);
10
+ const outputDir = resolve(options.output);
11
+ if (inputDir === outputDir) {
12
+ throw new SameInputOutputError();
13
+ }
14
+ const variables = loadVariables(options.vars);
15
+ const files = await scanTemplates(inputDir, {
16
+ include: options.include,
17
+ exclude: options.exclude,
18
+ });
19
+ const changes = [];
20
+ const warnings = [];
21
+ for (const filePath of files) {
22
+ const template = readTemplate(filePath);
23
+ const result = renderTemplate(template.content, variables);
24
+ const relativePath = relative(inputDir, filePath);
25
+ const outputPath = join(outputDir, relativePath);
26
+ let status;
27
+ if (!existsSync(outputPath)) {
28
+ status = "create";
29
+ }
30
+ else {
31
+ const existingContent = readFileSync(outputPath, "utf-8");
32
+ status = existingContent === result.content ? "unchanged" : "update";
33
+ }
34
+ changes.push({
35
+ relativePath,
36
+ outputPath,
37
+ status,
38
+ undefinedVariables: result.undefinedVariables,
39
+ });
40
+ for (const varName of result.undefinedVariables) {
41
+ warnings.push(`Warning: undefined variable "{{${varName}}}" in ${relativePath}`);
42
+ }
43
+ }
44
+ return { changes, warnings };
45
+ }
@@ -0,0 +1,16 @@
1
+ export interface ListVariablesOptions {
2
+ input: string;
3
+ vars: string;
4
+ include?: string;
5
+ exclude?: string;
6
+ }
7
+ export interface VariableUsage {
8
+ name: string;
9
+ files: string[];
10
+ isDefined: boolean;
11
+ }
12
+ export interface ListVariablesResult {
13
+ variables: VariableUsage[];
14
+ unusedVariables: string[];
15
+ }
16
+ export declare function listVariables(options: ListVariablesOptions): Promise<ListVariablesResult>;
@@ -0,0 +1,48 @@
1
+ import { readFileSync, existsSync } from "node:fs";
2
+ import { relative } from "node:path";
3
+ import { scanTemplates } from "../../infrastructure/services/file-scanner.js";
4
+ import { loadVariables } from "../../infrastructure/repositories/variables-repository.js";
5
+ import { VariablesFileNotFoundError } from "../../shared/errors.js";
6
+ const VARIABLE_PATTERN = /\{\{([\w.]+)\}\}/g;
7
+ export async function listVariables(options) {
8
+ const { input, vars, include, exclude } = options;
9
+ if (!existsSync(vars)) {
10
+ throw new VariablesFileNotFoundError(vars);
11
+ }
12
+ // Load defined variables
13
+ const definedVars = loadVariables(vars);
14
+ const definedVarNames = new Set(Object.keys(definedVars));
15
+ // Scan templates and extract variable usage
16
+ const templateFiles = await scanTemplates(input, { include, exclude });
17
+ const variableUsageMap = new Map();
18
+ for (const file of templateFiles) {
19
+ const content = readFileSync(file, "utf-8");
20
+ const relativePath = relative(input, file);
21
+ let match;
22
+ while ((match = VARIABLE_PATTERN.exec(content)) !== null) {
23
+ const varName = match[1];
24
+ if (!variableUsageMap.has(varName)) {
25
+ variableUsageMap.set(varName, new Set());
26
+ }
27
+ variableUsageMap.get(varName).add(relativePath);
28
+ }
29
+ }
30
+ // Build result
31
+ const usedVarNames = new Set(variableUsageMap.keys());
32
+ const variables = [];
33
+ // Variables used in templates
34
+ for (const [name, files] of variableUsageMap) {
35
+ variables.push({
36
+ name,
37
+ files: Array.from(files).sort(),
38
+ isDefined: definedVarNames.has(name),
39
+ });
40
+ }
41
+ // Sort by name
42
+ variables.sort((a, b) => a.name.localeCompare(b.name));
43
+ // Find unused variables (defined but not used)
44
+ const unusedVariables = Array.from(definedVarNames)
45
+ .filter((name) => !usedVarNames.has(name))
46
+ .sort();
47
+ return { variables, unusedVariables };
48
+ }
@@ -0,0 +1,2 @@
1
+ import type { CliOptions, ProcessResult } from "../../shared/types.js";
2
+ export declare function processTemplates(options: CliOptions): Promise<ProcessResult>;
@@ -0,0 +1,32 @@
1
+ import { resolve, relative, join } from "node:path";
2
+ import { SameInputOutputError } from "../../shared/errors.js";
3
+ import { loadVariables } from "../../infrastructure/repositories/variables-repository.js";
4
+ import { readTemplate, writeTemplate } from "../../infrastructure/repositories/template-repository.js";
5
+ import { scanTemplates } from "../../infrastructure/services/file-scanner.js";
6
+ import { renderTemplate } from "../../domain/services/template-renderer.js";
7
+ export async function processTemplates(options) {
8
+ const inputDir = resolve(options.input);
9
+ const outputDir = resolve(options.output);
10
+ if (inputDir === outputDir) {
11
+ throw new SameInputOutputError();
12
+ }
13
+ const variables = loadVariables(options.vars);
14
+ const files = await scanTemplates(inputDir, {
15
+ include: options.include,
16
+ exclude: options.exclude,
17
+ });
18
+ const processedFiles = [];
19
+ const warnings = [];
20
+ for (const filePath of files) {
21
+ const template = readTemplate(filePath);
22
+ const result = renderTemplate(template.content, variables);
23
+ const relativePath = relative(inputDir, filePath);
24
+ const outputPath = join(outputDir, relativePath);
25
+ writeTemplate(outputPath, result.content);
26
+ processedFiles.push(relativePath);
27
+ for (const varName of result.undefinedVariables) {
28
+ warnings.push(`Warning: undefined variable "{{${varName}}}" in ${relativePath}`);
29
+ }
30
+ }
31
+ return { processedFiles, warnings };
32
+ }
@@ -0,0 +1,13 @@
1
+ export interface RenameOptions {
2
+ input: string;
3
+ vars: string;
4
+ from: string;
5
+ to: string;
6
+ include?: string;
7
+ exclude?: string;
8
+ }
9
+ export interface RenameResult {
10
+ renamedInFiles: string[];
11
+ renamedInVars: boolean;
12
+ }
13
+ export declare function renameVariable(options: RenameOptions): Promise<RenameResult>;
@@ -0,0 +1,80 @@
1
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
2
+ import { parse, stringify } from "yaml";
3
+ import { scanTemplates } from "../../infrastructure/services/file-scanner.js";
4
+ import { VariablesFileNotFoundError } from "../../shared/errors.js";
5
+ function escapeRegex(str) {
6
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7
+ }
8
+ export async function renameVariable(options) {
9
+ const { input, vars, from, to, include, exclude } = options;
10
+ if (!existsSync(vars)) {
11
+ throw new VariablesFileNotFoundError(vars);
12
+ }
13
+ const result = {
14
+ renamedInFiles: [],
15
+ renamedInVars: false,
16
+ };
17
+ // Rename in template files
18
+ const templateFiles = await scanTemplates(input, { include, exclude });
19
+ const pattern = new RegExp(`\\{\\{${escapeRegex(from)}(\\}\\}|\\|)`, "g");
20
+ for (const file of templateFiles) {
21
+ const content = readFileSync(file, "utf-8");
22
+ const newContent = content.replace(pattern, `{{${to}$1`);
23
+ if (content !== newContent) {
24
+ writeFileSync(file, newContent, "utf-8");
25
+ result.renamedInFiles.push(file);
26
+ }
27
+ }
28
+ // Rename in variables file
29
+ const varsContent = readFileSync(vars, "utf-8");
30
+ const varsData = parse(varsContent);
31
+ if (renameKeyInObject(varsData, from, to)) {
32
+ writeFileSync(vars, stringify(varsData), "utf-8");
33
+ result.renamedInVars = true;
34
+ }
35
+ return result;
36
+ }
37
+ function renameKeyInObject(obj, from, to) {
38
+ const fromParts = from.split(".");
39
+ const toParts = to.split(".");
40
+ // Simple key rename (no dots)
41
+ if (fromParts.length === 1 && toParts.length === 1) {
42
+ if (from in obj) {
43
+ obj[to] = obj[from];
44
+ delete obj[from];
45
+ return true;
46
+ }
47
+ return false;
48
+ }
49
+ // Nested key rename
50
+ const fromParent = getNestedParent(obj, fromParts);
51
+ const fromKey = fromParts[fromParts.length - 1];
52
+ if (!fromParent || !(fromKey in fromParent)) {
53
+ return false;
54
+ }
55
+ const value = fromParent[fromKey];
56
+ delete fromParent[fromKey];
57
+ // Create target path if needed
58
+ const toParentParts = toParts.slice(0, -1);
59
+ const toKey = toParts[toParts.length - 1];
60
+ let toParent = obj;
61
+ for (const part of toParentParts) {
62
+ if (!(part in toParent)) {
63
+ toParent[part] = {};
64
+ }
65
+ toParent = toParent[part];
66
+ }
67
+ toParent[toKey] = value;
68
+ return true;
69
+ }
70
+ function getNestedParent(obj, parts) {
71
+ let current = obj;
72
+ for (let i = 0; i < parts.length - 1; i++) {
73
+ const part = parts[i];
74
+ if (!(part in current) || typeof current[part] !== "object") {
75
+ return null;
76
+ }
77
+ current = current[part];
78
+ }
79
+ return current;
80
+ }
@@ -0,0 +1,5 @@
1
+ export declare class Template {
2
+ readonly path: string;
3
+ readonly content: string;
4
+ constructor(path: string, content: string);
5
+ }
@@ -0,0 +1,8 @@
1
+ export class Template {
2
+ path;
3
+ content;
4
+ constructor(path, content) {
5
+ this.path = path;
6
+ this.content = content;
7
+ }
8
+ }
@@ -0,0 +1,3 @@
1
+ import type { Variables } from "../value-objects/variables.js";
2
+ import type { RenderResult } from "../../shared/types.js";
3
+ export declare function renderTemplate(content: string, variables: Variables): RenderResult;
@@ -0,0 +1,15 @@
1
+ const VARIABLE_PATTERN = /\{\{([\w.]+)\}\}/g;
2
+ export function renderTemplate(content, variables) {
3
+ const undefinedVariables = [];
4
+ const renderedContent = content.replace(VARIABLE_PATTERN, (match, varName) => {
5
+ if (varName in variables) {
6
+ return variables[varName];
7
+ }
8
+ undefinedVariables.push(varName);
9
+ return match;
10
+ });
11
+ return {
12
+ content: renderedContent,
13
+ undefinedVariables,
14
+ };
15
+ }
@@ -0,0 +1,9 @@
1
+ import { z } from "zod";
2
+ declare const PrimitiveValueSchema: z.ZodUnion<[z.ZodString, z.ZodNumber, z.ZodBoolean]>;
3
+ type NestedValue = z.infer<typeof PrimitiveValueSchema> | {
4
+ [key: string]: NestedValue;
5
+ };
6
+ export declare const VariablesSchema: z.ZodRecord<z.ZodString, z.ZodType<NestedValue, z.ZodTypeDef, NestedValue>>;
7
+ export type Variables = Record<string, string>;
8
+ export declare function flattenVariables(obj: Record<string, NestedValue>, prefix?: string): Variables;
9
+ export {};
@@ -0,0 +1,17 @@
1
+ import { z } from "zod";
2
+ const PrimitiveValueSchema = z.union([z.string(), z.number(), z.boolean()]);
3
+ const NestedValueSchema = z.lazy(() => z.union([PrimitiveValueSchema, z.record(z.string(), NestedValueSchema)]));
4
+ export const VariablesSchema = z.record(z.string(), NestedValueSchema);
5
+ export function flattenVariables(obj, prefix = "") {
6
+ const result = {};
7
+ for (const [key, value] of Object.entries(obj)) {
8
+ const fullKey = prefix ? `${prefix}.${key}` : key;
9
+ if (typeof value === "object" && value !== null) {
10
+ Object.assign(result, flattenVariables(value, fullKey));
11
+ }
12
+ else {
13
+ result[fullKey] = String(value);
14
+ }
15
+ }
16
+ return result;
17
+ }
@@ -0,0 +1,3 @@
1
+ import { Template } from "../../domain/entities/template.js";
2
+ export declare function readTemplate(filePath: string): Template;
3
+ export declare function writeTemplate(filePath: string, content: string): void;
@@ -0,0 +1,11 @@
1
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { Template } from "../../domain/entities/template.js";
4
+ export function readTemplate(filePath) {
5
+ const content = readFileSync(filePath, "utf-8");
6
+ return new Template(filePath, content);
7
+ }
8
+ export function writeTemplate(filePath, content) {
9
+ mkdirSync(dirname(filePath), { recursive: true });
10
+ writeFileSync(filePath, content, "utf-8");
11
+ }
@@ -0,0 +1,2 @@
1
+ import { type Variables } from "../../domain/value-objects/variables.js";
2
+ export declare function loadVariables(filePath: string): Variables;
@@ -0,0 +1,16 @@
1
+ import { readFileSync, existsSync } from "node:fs";
2
+ import { parse } from "yaml";
3
+ import { VariablesSchema, flattenVariables } from "../../domain/value-objects/variables.js";
4
+ import { VariablesFileNotFoundError, InvalidVariablesError } from "../../shared/errors.js";
5
+ export function loadVariables(filePath) {
6
+ if (!existsSync(filePath)) {
7
+ throw new VariablesFileNotFoundError(filePath);
8
+ }
9
+ const content = readFileSync(filePath, "utf-8");
10
+ const parsed = parse(content);
11
+ const result = VariablesSchema.safeParse(parsed);
12
+ if (!result.success) {
13
+ throw new InvalidVariablesError(result.error.message);
14
+ }
15
+ return flattenVariables(result.data);
16
+ }
@@ -0,0 +1,5 @@
1
+ export interface ScanOptions {
2
+ include?: string;
3
+ exclude?: string;
4
+ }
5
+ export declare function scanTemplates(inputDir: string, options?: ScanOptions): Promise<string[]>;
@@ -0,0 +1,8 @@
1
+ import fg from "fast-glob";
2
+ export async function scanTemplates(inputDir, options = {}) {
3
+ const pattern = options.include
4
+ ? `${inputDir}/${options.include}`
5
+ : `${inputDir}/**/*.md`;
6
+ const ignore = options.exclude ? [`${inputDir}/${options.exclude}`] : [];
7
+ return fg(pattern, { ignore });
8
+ }
@@ -0,0 +1,48 @@
1
+ export declare const mainCommand: import("citty").CommandDef<{
2
+ input: {
3
+ type: "positional";
4
+ description: string;
5
+ required: true;
6
+ };
7
+ output: {
8
+ type: "positional";
9
+ description: string;
10
+ required: true;
11
+ };
12
+ vars: {
13
+ type: "string";
14
+ description: string;
15
+ default: string;
16
+ };
17
+ include: {
18
+ type: "string";
19
+ description: string;
20
+ };
21
+ exclude: {
22
+ type: "string";
23
+ description: string;
24
+ };
25
+ watch: {
26
+ type: "boolean";
27
+ description: string;
28
+ default: false;
29
+ };
30
+ "rename-from": {
31
+ type: "string";
32
+ description: string;
33
+ };
34
+ "rename-to": {
35
+ type: "string";
36
+ description: string;
37
+ };
38
+ "list-vars": {
39
+ type: "boolean";
40
+ description: string;
41
+ default: false;
42
+ };
43
+ "dry-run": {
44
+ type: "boolean";
45
+ description: string;
46
+ default: false;
47
+ };
48
+ }>;
@@ -0,0 +1,311 @@
1
+ import { defineCommand } from "citty";
2
+ import { resolve } from "node:path";
3
+ import pc from "picocolors";
4
+ import Table from "cli-table3";
5
+ import { watch } from "chokidar";
6
+ import { processTemplates } from "../../../application/use-cases/process-templates.js";
7
+ import { renameVariable } from "../../../application/use-cases/rename-variable.js";
8
+ import { listVariables } from "../../../application/use-cases/list-variables.js";
9
+ import { dryRun } from "../../../application/use-cases/dry-run.js";
10
+ import { VariablesFileNotFoundError, SameInputOutputError, InvalidVariablesError } from "../../../shared/errors.js";
11
+ async function runProcess(options) {
12
+ try {
13
+ const result = await processTemplates(options);
14
+ if (result.warnings.length > 0) {
15
+ for (const warning of result.warnings) {
16
+ console.warn(pc.yellow(`⚠ ${warning}`));
17
+ }
18
+ console.log();
19
+ }
20
+ if (result.processedFiles.length === 0) {
21
+ console.log(pc.gray("No files to process"));
22
+ return true;
23
+ }
24
+ const table = new Table({
25
+ head: [pc.bold("File"), pc.bold("Status")],
26
+ style: { head: [], border: [] },
27
+ });
28
+ for (const file of result.processedFiles) {
29
+ table.push([file, pc.green("✓ done")]);
30
+ }
31
+ console.log(pc.bold(pc.cyan("\n✨ Build complete\n")));
32
+ console.log(table.toString());
33
+ console.log();
34
+ console.log(pc.bold("Processed: ") + pc.green(`${result.processedFiles.length} file(s)`));
35
+ return true;
36
+ }
37
+ catch (error) {
38
+ if (error instanceof VariablesFileNotFoundError ||
39
+ error instanceof SameInputOutputError ||
40
+ error instanceof InvalidVariablesError) {
41
+ console.error(pc.red(`✗ Error: ${error.message}`));
42
+ return false;
43
+ }
44
+ throw error;
45
+ }
46
+ }
47
+ function startWatch(options) {
48
+ const inputPath = resolve(options.input);
49
+ const varsPath = resolve(options.vars);
50
+ let debounceTimer = null;
51
+ const handleChange = (eventType, filePath) => {
52
+ if (debounceTimer) {
53
+ clearTimeout(debounceTimer);
54
+ }
55
+ debounceTimer = setTimeout(async () => {
56
+ console.log(pc.cyan(`\n👀 Change detected: ${pc.bold(filePath)} (${eventType})\n`));
57
+ await runProcess(options);
58
+ }, 100);
59
+ };
60
+ console.log(pc.bold(pc.magenta("\n👁 Watch mode enabled\n")));
61
+ const watchTable = new Table({
62
+ style: { head: [], border: [] },
63
+ });
64
+ watchTable.push([pc.gray("Templates"), inputPath], [pc.gray("Variables"), varsPath]);
65
+ console.log(watchTable.toString());
66
+ console.log();
67
+ console.log(pc.gray("Waiting for changes... (Ctrl+C to stop)\n"));
68
+ const watcher = watch([inputPath, varsPath], {
69
+ ignoreInitial: true,
70
+ ignored: /(^|[\/\\])\../,
71
+ });
72
+ watcher.on("all", handleChange);
73
+ }
74
+ export const mainCommand = defineCommand({
75
+ meta: {
76
+ name: "docvars",
77
+ description: "Replace {{variables}} in document templates with YAML values",
78
+ },
79
+ args: {
80
+ input: {
81
+ type: "positional",
82
+ description: "Input directory containing templates",
83
+ required: true,
84
+ },
85
+ output: {
86
+ type: "positional",
87
+ description: "Output directory for processed files",
88
+ required: true,
89
+ },
90
+ vars: {
91
+ type: "string",
92
+ description: "Path to variables YAML file",
93
+ default: "variables.yaml",
94
+ },
95
+ include: {
96
+ type: "string",
97
+ description: "Glob pattern to include files",
98
+ },
99
+ exclude: {
100
+ type: "string",
101
+ description: "Glob pattern to exclude files",
102
+ },
103
+ watch: {
104
+ type: "boolean",
105
+ description: "Watch for file changes and rebuild automatically",
106
+ default: false,
107
+ },
108
+ "rename-from": {
109
+ type: "string",
110
+ description: "Variable name to rename from (use with --rename-to)",
111
+ },
112
+ "rename-to": {
113
+ type: "string",
114
+ description: "Variable name to rename to (use with --rename-from)",
115
+ },
116
+ "list-vars": {
117
+ type: "boolean",
118
+ description: "List all variables used in templates",
119
+ default: false,
120
+ },
121
+ "dry-run": {
122
+ type: "boolean",
123
+ description: "Preview changes without writing files",
124
+ default: false,
125
+ },
126
+ },
127
+ async run({ args }) {
128
+ // Handle dry-run mode
129
+ if (args["dry-run"]) {
130
+ try {
131
+ const result = await dryRun({
132
+ input: args.input,
133
+ output: args.output,
134
+ vars: args.vars,
135
+ include: args.include,
136
+ exclude: args.exclude,
137
+ });
138
+ console.log(pc.bold(pc.cyan("\n🔍 Dry run - no files written\n")));
139
+ if (result.warnings.length > 0) {
140
+ for (const warning of result.warnings) {
141
+ console.warn(pc.yellow(`⚠ ${warning}`));
142
+ }
143
+ console.log();
144
+ }
145
+ if (result.changes.length === 0) {
146
+ console.log(pc.gray("No files to process"));
147
+ return;
148
+ }
149
+ const table = new Table({
150
+ head: [pc.bold("File"), pc.bold("Status")],
151
+ style: { head: [], border: [] },
152
+ });
153
+ for (const change of result.changes) {
154
+ let status;
155
+ switch (change.status) {
156
+ case "create":
157
+ status = pc.green("+ create");
158
+ break;
159
+ case "update":
160
+ status = pc.yellow("~ update");
161
+ break;
162
+ case "unchanged":
163
+ status = pc.gray("= unchanged");
164
+ break;
165
+ }
166
+ table.push([change.relativePath, status]);
167
+ }
168
+ console.log(table.toString());
169
+ console.log();
170
+ const creates = result.changes.filter((c) => c.status === "create").length;
171
+ const updates = result.changes.filter((c) => c.status === "update").length;
172
+ const unchanged = result.changes.filter((c) => c.status === "unchanged").length;
173
+ console.log(pc.bold("Summary: ") +
174
+ pc.green(`${creates} create`) +
175
+ pc.gray(" · ") +
176
+ pc.yellow(`${updates} update`) +
177
+ pc.gray(" · ") +
178
+ pc.gray(`${unchanged} unchanged`));
179
+ }
180
+ catch (error) {
181
+ if (error instanceof VariablesFileNotFoundError ||
182
+ error instanceof SameInputOutputError ||
183
+ error instanceof InvalidVariablesError) {
184
+ console.error(`Error: ${error.message}`);
185
+ process.exit(1);
186
+ }
187
+ throw error;
188
+ }
189
+ return;
190
+ }
191
+ // Handle list-vars mode
192
+ if (args["list-vars"]) {
193
+ try {
194
+ const result = await listVariables({
195
+ input: args.input,
196
+ vars: args.vars,
197
+ include: args.include,
198
+ exclude: args.exclude,
199
+ });
200
+ console.log(pc.bold(pc.cyan("\n📋 Variables\n")));
201
+ if (result.variables.length === 0 && result.unusedVariables.length === 0) {
202
+ console.log(pc.gray("No variables found"));
203
+ return;
204
+ }
205
+ if (result.variables.length > 0) {
206
+ const table = new Table({
207
+ head: [pc.bold("Variable"), pc.bold("Status"), pc.bold("Used in")],
208
+ style: { head: [], border: [] },
209
+ wordWrap: true,
210
+ });
211
+ for (const v of result.variables) {
212
+ const status = v.isDefined ? pc.green("✓ defined") : pc.red("✗ undefined");
213
+ const files = v.files.map((f) => pc.gray(f)).join("\n");
214
+ table.push([v.name, status, files]);
215
+ }
216
+ console.log(table.toString());
217
+ }
218
+ if (result.unusedVariables.length > 0) {
219
+ console.log(pc.bold(pc.yellow("\n⚠ Unused variables (defined but not used):\n")));
220
+ for (const name of result.unusedVariables) {
221
+ console.log(pc.gray(` ${name}`));
222
+ }
223
+ }
224
+ console.log();
225
+ const defined = result.variables.filter((v) => v.isDefined).length;
226
+ const undefined_ = result.variables.filter((v) => !v.isDefined).length;
227
+ console.log(pc.bold("Summary: ") +
228
+ pc.green(`${defined} defined`) +
229
+ pc.gray(" · ") +
230
+ (undefined_ > 0 ? pc.red(`${undefined_} undefined`) : pc.gray("0 undefined")) +
231
+ pc.gray(" · ") +
232
+ pc.yellow(`${result.unusedVariables.length} unused`));
233
+ }
234
+ catch (error) {
235
+ if (error instanceof VariablesFileNotFoundError) {
236
+ console.error(pc.red(`✗ Error: ${error.message}`));
237
+ process.exit(1);
238
+ }
239
+ throw error;
240
+ }
241
+ return;
242
+ }
243
+ // Handle rename mode
244
+ if (args["rename-from"] || args["rename-to"]) {
245
+ if (!args["rename-from"] || !args["rename-to"]) {
246
+ console.error(pc.red("✗ Error: Both --rename-from and --rename-to are required"));
247
+ process.exit(1);
248
+ }
249
+ try {
250
+ const result = await renameVariable({
251
+ input: args.input,
252
+ vars: args.vars,
253
+ from: args["rename-from"],
254
+ to: args["rename-to"],
255
+ include: args.include,
256
+ exclude: args.exclude,
257
+ });
258
+ if (result.renamedInFiles.length === 0 && !result.renamedInVars) {
259
+ console.log(pc.yellow(`⚠ No occurrences of "${args["rename-from"]}" found`));
260
+ }
261
+ else {
262
+ console.log(pc.bold(pc.cyan("\n✏️ Rename complete\n")) +
263
+ pc.gray(` ${args["rename-from"]}`) +
264
+ pc.cyan(" → ") +
265
+ pc.green(args["rename-to"]) +
266
+ "\n");
267
+ const table = new Table({
268
+ head: [pc.bold("File"), pc.bold("Status")],
269
+ style: { head: [], border: [] },
270
+ });
271
+ if (result.renamedInVars) {
272
+ table.push([pc.italic("variables.yaml"), pc.green("✓ updated")]);
273
+ }
274
+ for (const file of result.renamedInFiles) {
275
+ table.push([file, pc.green("✓ updated")]);
276
+ }
277
+ console.log(table.toString());
278
+ console.log();
279
+ const total = result.renamedInFiles.length + (result.renamedInVars ? 1 : 0);
280
+ console.log(pc.bold("Updated: ") + pc.green(`${total} file(s)`));
281
+ }
282
+ }
283
+ catch (error) {
284
+ if (error instanceof VariablesFileNotFoundError) {
285
+ console.error(pc.red(`✗ Error: ${error.message}`));
286
+ process.exit(1);
287
+ }
288
+ throw error;
289
+ }
290
+ return;
291
+ }
292
+ // Normal processing mode
293
+ const options = {
294
+ input: args.input,
295
+ output: args.output,
296
+ vars: args.vars,
297
+ include: args.include,
298
+ exclude: args.exclude,
299
+ };
300
+ const success = await runProcess(options);
301
+ if (args.watch) {
302
+ if (!success) {
303
+ console.log("\nFix errors and save to retry...\n");
304
+ }
305
+ startWatch(options);
306
+ }
307
+ else if (!success) {
308
+ process.exit(1);
309
+ }
310
+ },
311
+ });
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { runMain } from "citty";
3
+ import { mainCommand } from "./commands/main.js";
4
+ runMain(mainCommand);
@@ -0,0 +1,9 @@
1
+ export declare class VariablesFileNotFoundError extends Error {
2
+ constructor(filePath: string);
3
+ }
4
+ export declare class SameInputOutputError extends Error {
5
+ constructor();
6
+ }
7
+ export declare class InvalidVariablesError extends Error {
8
+ constructor(message: string);
9
+ }
@@ -0,0 +1,18 @@
1
+ export class VariablesFileNotFoundError extends Error {
2
+ constructor(filePath) {
3
+ super(`Variables file not found: ${filePath}`);
4
+ this.name = "VariablesFileNotFoundError";
5
+ }
6
+ }
7
+ export class SameInputOutputError extends Error {
8
+ constructor() {
9
+ super("Input and output directories cannot be the same");
10
+ this.name = "SameInputOutputError";
11
+ }
12
+ }
13
+ export class InvalidVariablesError extends Error {
14
+ constructor(message) {
15
+ super(`Invalid variables file: ${message}`);
16
+ this.name = "InvalidVariablesError";
17
+ }
18
+ }
@@ -0,0 +1,15 @@
1
+ export interface CliOptions {
2
+ input: string;
3
+ output: string;
4
+ vars: string;
5
+ include?: string;
6
+ exclude?: string;
7
+ }
8
+ export interface ProcessResult {
9
+ processedFiles: string[];
10
+ warnings: string[];
11
+ }
12
+ export interface RenderResult {
13
+ content: string;
14
+ undefinedVariables: string[];
15
+ }
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "docvars",
3
+ "author": "Shunta Toda",
4
+ "version": "0.3.0",
5
+ "description": "Replace {{variables}} in document templates with YAML values",
6
+ "type": "module",
7
+ "main": "./dist/application/use-cases/process-templates.js",
8
+ "types": "./dist/application/use-cases/process-templates.d.ts",
9
+ "bin": {
10
+ "docvars": "./dist/presentation/cli/index.js"
11
+ },
12
+ "files": [
13
+ "dist"
14
+ ],
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "start": "node ./dist/presentation/cli/index.js",
18
+ "dev": "tsc --watch",
19
+ "test": "vitest",
20
+ "prepublishOnly": "pnpm test run && pnpm build"
21
+ },
22
+ "keywords": [
23
+ "template",
24
+ "variables",
25
+ "yaml",
26
+ "cli",
27
+ "documents",
28
+ "markdown",
29
+ "html",
30
+ "text"
31
+ ],
32
+ "license": "MIT",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "https://github.com/ShuntaToda/docvars.git"
36
+ },
37
+ "homepage": "https://github.com/ShuntaToda/docvars#readme",
38
+ "bugs": {
39
+ "url": "https://github.com/ShuntaToda/docvars/issues"
40
+ },
41
+ "engines": {
42
+ "node": ">=18"
43
+ },
44
+ "dependencies": {
45
+ "chokidar": "^5.0.0",
46
+ "citty": "^0.1.6",
47
+ "cli-table3": "^0.6.5",
48
+ "fast-glob": "^3.3.2",
49
+ "picocolors": "^1.1.1",
50
+ "yaml": "^2.3.4",
51
+ "zod": "^3.22.4"
52
+ },
53
+ "devDependencies": {
54
+ "@types/node": "^20.11.0",
55
+ "typescript": "^5.3.3",
56
+ "vitest": "^1.2.0"
57
+ }
58
+ }