@gregorlohaus/tdir 0.1.2 → 0.1.4
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/README.md +96 -9
- package/dist/cli.js +269 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +633 -94
- package/dist/render.d.ts +39 -1
- package/dist/reverse.d.ts +14 -0
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# tdir
|
|
2
2
|
|
|
3
|
-
Treat a directory as a template. File paths and contents support conditionals (`@if`/`@elseif`/`@else`) and variable substitution (`@var`). Provide a Zod schema and tdir validates it matches the template at setup time, then validates context at render time.
|
|
3
|
+
Treat a directory as a template. File paths and text file contents support conditionals (`@if`/`@elseif`/`@else`) and variable substitution (`@var`). Provide a Zod schema and tdir validates it matches the template at setup time, then validates context at render time. Binary files are copied through unchanged.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -32,7 +32,7 @@ Where `index.html` contains:
|
|
|
32
32
|
Render it:
|
|
33
33
|
|
|
34
34
|
```ts
|
|
35
|
-
import { initRenderer } from "tdir"
|
|
35
|
+
import { initRenderer } from "@gregorlohaus/tdir"
|
|
36
36
|
import { z } from "zod"
|
|
37
37
|
|
|
38
38
|
const createRenderer = initRenderer("./templates")
|
|
@@ -58,7 +58,7 @@ render("./output", {
|
|
|
58
58
|
|
|
59
59
|
| Directive | Description |
|
|
60
60
|
|---|---|
|
|
61
|
-
| `<@if(context.x)>` | Conditional block —
|
|
61
|
+
| `<@if(context.x)>` | Conditional block — boolean check (must end with `<@endif>`) |
|
|
62
62
|
| `<@if(eq(context.x,"value"))>` | Conditional block — string equality check |
|
|
63
63
|
| `<@elseif(context.y)>` | Else-if branch (same forms as `@if`) |
|
|
64
64
|
| `<@else>` | Else branch |
|
|
@@ -70,7 +70,7 @@ render("./output", {
|
|
|
70
70
|
|
|
71
71
|
| Directive | Description |
|
|
72
72
|
|---|---|
|
|
73
|
-
| `<@if(context.x)>dirname` | Conditionally include directory/file (
|
|
73
|
+
| `<@if(context.x)>dirname` | Conditionally include directory/file (boolean check) |
|
|
74
74
|
| `<@if(eq(context.x,"value"))>dirname` | Conditionally include by string equality |
|
|
75
75
|
| `<@var(context.x)>` | Dynamic directory/file name |
|
|
76
76
|
|
|
@@ -81,7 +81,7 @@ These can be combined: `<@if(context.web.create)><@var(context.web.dir)>` create
|
|
|
81
81
|
`createRenderer` validates that your Zod schema matches the template variables. Mismatches throw `SchemaMismatchError`:
|
|
82
82
|
|
|
83
83
|
```ts
|
|
84
|
-
import { initRenderer, SchemaMismatchError } from "tdir"
|
|
84
|
+
import { initRenderer, SchemaMismatchError } from "@gregorlohaus/tdir"
|
|
85
85
|
import { z } from "zod"
|
|
86
86
|
|
|
87
87
|
const createRenderer = initRenderer("./templates")
|
|
@@ -92,14 +92,14 @@ createRenderer(z.object({
|
|
|
92
92
|
web: z.string(), // wrong type
|
|
93
93
|
header: z.object({ show: z.boolean(), title: z.string() })
|
|
94
94
|
}))
|
|
95
|
-
// SchemaMismatchError:
|
|
95
|
+
// SchemaMismatchError: Schema doesn't match used template variables: web: expected z.boolean() but schema has z.string()
|
|
96
96
|
|
|
97
97
|
// Schema is missing fields used in templates -- throws SchemaMismatchError
|
|
98
98
|
createRenderer(z.object({
|
|
99
99
|
web: z.boolean()
|
|
100
100
|
// missing header
|
|
101
101
|
}))
|
|
102
|
-
// SchemaMismatchError:
|
|
102
|
+
// SchemaMismatchError: Schema doesn't match used template variables: header: missing in schema
|
|
103
103
|
```
|
|
104
104
|
|
|
105
105
|
## Context validation
|
|
@@ -123,12 +123,99 @@ render("./output", { web: "not a boolean", header: { show: true, title: "Hi" } }
|
|
|
123
123
|
|
|
124
124
|
`render(target, context)` clears `target` before writing, so rendering the same template into the same directory with different contexts always produces a clean result (files/paths excluded by conditionals won't linger from a previous run).
|
|
125
125
|
|
|
126
|
+
For safety, tdir refuses to render into the filesystem root, the current working directory, the home directory, or any directory that overlaps the template source. Dynamic file and directory names are also resolved against the output directory and cannot write outside it.
|
|
127
|
+
|
|
128
|
+
## Reverse maps
|
|
129
|
+
|
|
130
|
+
Pass `{ reverseMap: true }` as the third render argument to write `.tdir-map.json` into the output directory:
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
render("./output", {
|
|
134
|
+
web: true,
|
|
135
|
+
header: { show: true, title: "Hello" }
|
|
136
|
+
}, { reverseMap: true })
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Pass a string to choose a custom JSON path inside the output directory:
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
render("./output", context, { reverseMap: "meta/reverse-map.json" })
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
The map contains a flat lookup from rendered strings to template tokens, per-file occurrences with path/range context, inline conditional blocks, and template files skipped by path conditionals:
|
|
146
|
+
|
|
147
|
+
```json
|
|
148
|
+
{
|
|
149
|
+
"version": 1,
|
|
150
|
+
"files": [
|
|
151
|
+
{
|
|
152
|
+
"outputPath": "web/index.html",
|
|
153
|
+
"templatePath": "<@if(context.web)>web/index.html",
|
|
154
|
+
"tokens": [
|
|
155
|
+
{
|
|
156
|
+
"kind": "content",
|
|
157
|
+
"result": "Hello",
|
|
158
|
+
"token": "<@var(context.header.title)>",
|
|
159
|
+
"contextPath": "header.title",
|
|
160
|
+
"outputPath": "web/index.html",
|
|
161
|
+
"templatePath": "<@if(context.web)>web/index.html",
|
|
162
|
+
"range": { "start": 16, "end": 21 }
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
"kind": "conditional",
|
|
166
|
+
"result": "<head><@var(context.header.title)></head>",
|
|
167
|
+
"token": "<@if(context.header.show)><head><@var(context.header.title)></head><@endif>",
|
|
168
|
+
"outputPath": "web/index.html",
|
|
169
|
+
"templatePath": "<@if(context.web)>web/index.html",
|
|
170
|
+
"range": { "start": 9, "end": 53 },
|
|
171
|
+
"activeRange": { "start": 27, "end": 71 }
|
|
172
|
+
}
|
|
173
|
+
]
|
|
174
|
+
}
|
|
175
|
+
],
|
|
176
|
+
"skipped": [
|
|
177
|
+
{
|
|
178
|
+
"kind": "file",
|
|
179
|
+
"templatePath": "<@if(context.docs)>docs/readme.md",
|
|
180
|
+
"encoding": "utf8",
|
|
181
|
+
"content": "# Docs"
|
|
182
|
+
}
|
|
183
|
+
],
|
|
184
|
+
"tokens": {
|
|
185
|
+
"Hello": ["<@var(context.header.title)>"]
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## Reverse CLI
|
|
191
|
+
|
|
192
|
+
Use the reverse map to rebuild template files from an edited rendered directory:
|
|
193
|
+
|
|
194
|
+
```sh
|
|
195
|
+
tdir reverse ./output ./templates
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Without installing the package first, run the published CLI through Bun:
|
|
199
|
+
|
|
200
|
+
```sh
|
|
201
|
+
bunx @gregorlohaus/tdir reverse ./output ./templates
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
By default, the command reads `./output/.tdir-map.json`. Use `--map` for a custom map path relative to the rendered directory:
|
|
205
|
+
|
|
206
|
+
```sh
|
|
207
|
+
tdir reverse ./output ./templates --map meta/reverse-map.json
|
|
208
|
+
bunx @gregorlohaus/tdir reverse ./output ./templates --map meta/reverse-map.json
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
The command writes files at their original template paths, restores recorded `<@var(...)>` tokens, wraps edited inline conditional output back in the original conditional block, and restores template files that were skipped by path conditionals.
|
|
212
|
+
|
|
126
213
|
## Unmatched directives
|
|
127
214
|
|
|
128
|
-
A `<@if>` without a matching `<@endif>` throws
|
|
215
|
+
A `<@if>` without a matching `<@endif>` throws when the renderer is initialized:
|
|
129
216
|
|
|
130
217
|
```ts
|
|
131
218
|
// If a template file contains <@if(context.x)> with no <@endif>
|
|
132
|
-
|
|
219
|
+
initRenderer("./templates")
|
|
133
220
|
// Error: Unmatched <@if> without <@endif>
|
|
134
221
|
```
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// reverse.ts
|
|
4
|
+
import {
|
|
5
|
+
copyFileSync,
|
|
6
|
+
existsSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
statSync,
|
|
10
|
+
writeFileSync
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import { dirname, isAbsolute, relative, resolve as resolvePath } from "node:path";
|
|
13
|
+
import { TextDecoder } from "node:util";
|
|
14
|
+
function isInsidePath(parent, child) {
|
|
15
|
+
const rel = relative(parent, child);
|
|
16
|
+
return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
|
|
17
|
+
}
|
|
18
|
+
function resolveInside(root, path) {
|
|
19
|
+
const resolved = resolvePath(root, path);
|
|
20
|
+
if (!isInsidePath(root, resolved)) {
|
|
21
|
+
throw new Error(`Refusing to write outside target directory: ${path}`);
|
|
22
|
+
}
|
|
23
|
+
return resolved;
|
|
24
|
+
}
|
|
25
|
+
function isUtf8Text(buffer) {
|
|
26
|
+
if (buffer.indexOf(0) !== -1)
|
|
27
|
+
return false;
|
|
28
|
+
try {
|
|
29
|
+
new TextDecoder("utf-8", { fatal: true }).decode(buffer);
|
|
30
|
+
return true;
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function readManifest(mapPath) {
|
|
36
|
+
const manifest = JSON.parse(readFileSync(mapPath, "utf-8"));
|
|
37
|
+
if (manifest.version !== 1 || !Array.isArray(manifest.files)) {
|
|
38
|
+
throw new Error(`Unsupported reverse map: ${mapPath}`);
|
|
39
|
+
}
|
|
40
|
+
return manifest;
|
|
41
|
+
}
|
|
42
|
+
function replaceAtRange(content, token) {
|
|
43
|
+
if (!token.range)
|
|
44
|
+
return null;
|
|
45
|
+
const { start, end } = token.range;
|
|
46
|
+
if (start < 0 || end < start || end > content.length)
|
|
47
|
+
return null;
|
|
48
|
+
if (content.slice(start, end) !== token.result)
|
|
49
|
+
return null;
|
|
50
|
+
return `${content.slice(0, start)}${token.token}${content.slice(end)}`;
|
|
51
|
+
}
|
|
52
|
+
function replaceFirst(content, token) {
|
|
53
|
+
const index = content.indexOf(token.result);
|
|
54
|
+
if (index === -1)
|
|
55
|
+
return null;
|
|
56
|
+
return `${content.slice(0, index)}${token.token}${content.slice(index + token.result.length)}`;
|
|
57
|
+
}
|
|
58
|
+
function applyActiveBranch(token, branchContent) {
|
|
59
|
+
if (!token.activeRange)
|
|
60
|
+
return token.token;
|
|
61
|
+
return [
|
|
62
|
+
token.token.slice(0, token.activeRange.start),
|
|
63
|
+
branchContent,
|
|
64
|
+
token.token.slice(token.activeRange.end)
|
|
65
|
+
].join("");
|
|
66
|
+
}
|
|
67
|
+
function findPrefixEnd(content, before) {
|
|
68
|
+
if (before === "")
|
|
69
|
+
return 0;
|
|
70
|
+
let candidate = before;
|
|
71
|
+
while (candidate.length >= 8) {
|
|
72
|
+
const index = content.indexOf(candidate);
|
|
73
|
+
if (index !== -1)
|
|
74
|
+
return index + candidate.length;
|
|
75
|
+
candidate = candidate.slice(-Math.max(1, Math.floor(candidate.length / 2)));
|
|
76
|
+
}
|
|
77
|
+
return -1;
|
|
78
|
+
}
|
|
79
|
+
function findSuffixStart(content, after, from) {
|
|
80
|
+
if (after === "")
|
|
81
|
+
return content.length;
|
|
82
|
+
let candidate = after;
|
|
83
|
+
while (candidate.length >= 8) {
|
|
84
|
+
const index = content.indexOf(candidate, from);
|
|
85
|
+
if (index !== -1)
|
|
86
|
+
return index;
|
|
87
|
+
candidate = candidate.slice(0, Math.floor(candidate.length / 2));
|
|
88
|
+
}
|
|
89
|
+
return -1;
|
|
90
|
+
}
|
|
91
|
+
function replaceConditional(content, token) {
|
|
92
|
+
const exactIndex = content.indexOf(token.result);
|
|
93
|
+
if (exactIndex !== -1) {
|
|
94
|
+
return [
|
|
95
|
+
content.slice(0, exactIndex),
|
|
96
|
+
token.token,
|
|
97
|
+
content.slice(exactIndex + token.result.length)
|
|
98
|
+
].join("");
|
|
99
|
+
}
|
|
100
|
+
if (token.before === undefined || token.after === undefined)
|
|
101
|
+
return null;
|
|
102
|
+
const branchStart = findPrefixEnd(content, token.before);
|
|
103
|
+
if (branchStart === -1)
|
|
104
|
+
return null;
|
|
105
|
+
const afterIndex = findSuffixStart(content, token.after, branchStart);
|
|
106
|
+
if (afterIndex === -1)
|
|
107
|
+
return null;
|
|
108
|
+
return [
|
|
109
|
+
content.slice(0, branchStart),
|
|
110
|
+
applyActiveBranch(token, content.slice(branchStart, afterIndex)),
|
|
111
|
+
content.slice(afterIndex)
|
|
112
|
+
].join("");
|
|
113
|
+
}
|
|
114
|
+
function reverseContent(content, file, warnings) {
|
|
115
|
+
const contentTokens = file.tokens.filter((token) => token.kind === "content").sort((a, b) => (b.range?.start ?? -1) - (a.range?.start ?? -1));
|
|
116
|
+
let reversed = content;
|
|
117
|
+
for (const token of contentTokens) {
|
|
118
|
+
const rangeResult = replaceAtRange(reversed, token);
|
|
119
|
+
if (rangeResult !== null) {
|
|
120
|
+
reversed = rangeResult;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const fallbackResult = replaceFirst(reversed, token);
|
|
124
|
+
if (fallbackResult !== null) {
|
|
125
|
+
reversed = fallbackResult;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
warnings.push({
|
|
129
|
+
outputPath: file.outputPath,
|
|
130
|
+
token: token.token,
|
|
131
|
+
result: token.result,
|
|
132
|
+
message: "Rendered value was not found; token was not restored"
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
const conditionalTokens = file.tokens.filter((token) => token.kind === "conditional").sort((a, b) => (b.range?.start ?? -1) - (a.range?.start ?? -1));
|
|
136
|
+
for (const token of conditionalTokens) {
|
|
137
|
+
const result = replaceConditional(reversed, token);
|
|
138
|
+
if (result !== null) {
|
|
139
|
+
reversed = result;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
warnings.push({
|
|
143
|
+
outputPath: file.outputPath,
|
|
144
|
+
token: token.token,
|
|
145
|
+
result: token.result,
|
|
146
|
+
message: "Rendered conditional block was not found; block was not restored"
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
return reversed;
|
|
150
|
+
}
|
|
151
|
+
function writeSkippedTemplate(templateRoot, skipped) {
|
|
152
|
+
const templatePath = resolveInside(templateRoot, skipped.templatePath);
|
|
153
|
+
if (skipped.kind === "directory") {
|
|
154
|
+
mkdirSync(templatePath, { recursive: true });
|
|
155
|
+
return 0;
|
|
156
|
+
}
|
|
157
|
+
mkdirSync(dirname(templatePath), { recursive: true });
|
|
158
|
+
const content = skipped.encoding === "base64" ? Buffer.from(skipped.content ?? "", "base64") : skipped.content ?? "";
|
|
159
|
+
writeFileSync(templatePath, content);
|
|
160
|
+
return 1;
|
|
161
|
+
}
|
|
162
|
+
function reverseDir(renderedDir, templateDir, options = {}) {
|
|
163
|
+
const renderedRoot = resolvePath(renderedDir);
|
|
164
|
+
const templateRoot = resolvePath(templateDir);
|
|
165
|
+
const mapPath = options.mapPath ? resolvePath(renderedRoot, options.mapPath) : resolvePath(renderedRoot, ".tdir-map.json");
|
|
166
|
+
const manifest = readManifest(mapPath);
|
|
167
|
+
const warnings = [];
|
|
168
|
+
let filesWritten = 0;
|
|
169
|
+
for (const file of manifest.files) {
|
|
170
|
+
const renderedPath = resolveInside(renderedRoot, file.outputPath);
|
|
171
|
+
const templatePath = resolveInside(templateRoot, file.templatePath);
|
|
172
|
+
if (!existsSync(renderedPath)) {
|
|
173
|
+
warnings.push({
|
|
174
|
+
outputPath: file.outputPath,
|
|
175
|
+
token: "",
|
|
176
|
+
result: "",
|
|
177
|
+
message: "Rendered path does not exist; skipped"
|
|
178
|
+
});
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const stat = statSync(renderedPath);
|
|
182
|
+
if (stat.isDirectory()) {
|
|
183
|
+
mkdirSync(templatePath, { recursive: true });
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (!stat.isFile())
|
|
187
|
+
continue;
|
|
188
|
+
mkdirSync(dirname(templatePath), { recursive: true });
|
|
189
|
+
const content = readFileSync(renderedPath);
|
|
190
|
+
if (!isUtf8Text(content)) {
|
|
191
|
+
copyFileSync(renderedPath, templatePath);
|
|
192
|
+
filesWritten += 1;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
writeFileSync(templatePath, reverseContent(content.toString("utf-8"), file, warnings));
|
|
196
|
+
filesWritten += 1;
|
|
197
|
+
}
|
|
198
|
+
for (const skipped of manifest.skipped ?? []) {
|
|
199
|
+
filesWritten += writeSkippedTemplate(templateRoot, skipped);
|
|
200
|
+
}
|
|
201
|
+
return { filesWritten, warnings };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// cli.ts
|
|
205
|
+
function printHelp() {
|
|
206
|
+
console.log(`tdir
|
|
207
|
+
|
|
208
|
+
Usage:
|
|
209
|
+
tdir reverse <rendered-dir> <template-dir> [--map <path>]
|
|
210
|
+
|
|
211
|
+
Commands:
|
|
212
|
+
reverse Rebuild template files from a rendered directory and reverse map
|
|
213
|
+
|
|
214
|
+
Options:
|
|
215
|
+
--map Reverse map path. Defaults to <rendered-dir>/.tdir-map.json.
|
|
216
|
+
Relative paths are resolved from <rendered-dir>.
|
|
217
|
+
--help Show this help message.
|
|
218
|
+
`);
|
|
219
|
+
}
|
|
220
|
+
function parseReverseArgs(args) {
|
|
221
|
+
const positional = [];
|
|
222
|
+
let mapPath;
|
|
223
|
+
for (let i = 0;i < args.length; i++) {
|
|
224
|
+
const arg = args[i];
|
|
225
|
+
if (arg === "--help" || arg === "-h") {
|
|
226
|
+
return { help: true, positional, mapPath };
|
|
227
|
+
}
|
|
228
|
+
if (arg === "--map") {
|
|
229
|
+
const value = args[++i];
|
|
230
|
+
if (!value)
|
|
231
|
+
throw new Error("Missing value for --map");
|
|
232
|
+
mapPath = value;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
positional.push(arg);
|
|
236
|
+
}
|
|
237
|
+
return { help: false, positional, mapPath };
|
|
238
|
+
}
|
|
239
|
+
function main(argv) {
|
|
240
|
+
const [command, ...args] = argv;
|
|
241
|
+
if (!command || command === "--help" || command === "-h") {
|
|
242
|
+
printHelp();
|
|
243
|
+
return 0;
|
|
244
|
+
}
|
|
245
|
+
if (command !== "reverse") {
|
|
246
|
+
throw new Error(`Unknown command: ${command}`);
|
|
247
|
+
}
|
|
248
|
+
const parsed = parseReverseArgs(args);
|
|
249
|
+
if (parsed.help) {
|
|
250
|
+
printHelp();
|
|
251
|
+
return 0;
|
|
252
|
+
}
|
|
253
|
+
const [renderedDir, templateDir] = parsed.positional;
|
|
254
|
+
if (!renderedDir || !templateDir || parsed.positional.length > 2) {
|
|
255
|
+
throw new Error("Usage: tdir reverse <rendered-dir> <template-dir> [--map <path>]");
|
|
256
|
+
}
|
|
257
|
+
const result = reverseDir(renderedDir, templateDir, { mapPath: parsed.mapPath });
|
|
258
|
+
console.log(`Wrote ${result.filesWritten} file${result.filesWritten === 1 ? "" : "s"}`);
|
|
259
|
+
for (const warning of result.warnings) {
|
|
260
|
+
console.warn(`Warning: ${warning.outputPath}: ${warning.message}`);
|
|
261
|
+
}
|
|
262
|
+
return result.warnings.length > 0 ? 2 : 0;
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
process.exitCode = main(process.argv.slice(2));
|
|
266
|
+
} catch (error) {
|
|
267
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
268
|
+
process.exitCode = 1;
|
|
269
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { type RenderOptions } from "./render";
|
|
3
|
+
export type { RenderOptions, ReverseMapFile, ReverseMapManifest, ReverseMapStoredTemplate, ReverseMapToken } from "./render";
|
|
4
|
+
export { reverseDir, type ReverseOptions, type ReverseResult, type ReverseWarning } from "./reverse";
|
|
2
5
|
interface Stringable {
|
|
3
6
|
toString: () => string;
|
|
4
7
|
}
|
|
@@ -9,5 +12,4 @@ interface Issue {
|
|
|
9
12
|
export declare class SchemaMismatchError extends Error {
|
|
10
13
|
constructor(issue: Issue);
|
|
11
14
|
}
|
|
12
|
-
export declare const initRenderer: (dirPath: string) => <S extends z.ZodType>(userSchema: S) => (targetPath: string, context: z.infer<S
|
|
13
|
-
export {};
|
|
15
|
+
export declare const initRenderer: (dirPath: string) => <S extends z.ZodType>(userSchema: S) => (targetPath: string, context: z.infer<S>, options?: RenderOptions) => void;
|
package/dist/index.js
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
|
+
// index.ts
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
1
4
|
// parser.ts
|
|
2
5
|
import { readdirSync, statSync, readFileSync } from "node:fs";
|
|
3
6
|
import { join } from "node:path";
|
|
7
|
+
import { TextDecoder } from "node:util";
|
|
4
8
|
var IF_RE = /<@(?:if|elseif)\((.+?)\)>/g;
|
|
5
9
|
var VAR_RE = /<@var\(context\.(.+?)(?::(\w+))?\)>/g;
|
|
10
|
+
var DIRECTIVE_RE = /<@(if|elseif|else|endif)(?:\((.+?)\))?>/g;
|
|
6
11
|
var EQ_RE = /^eq\(context\.(.+?),\s*"(.*)"\)$/;
|
|
7
12
|
var PATH_RE = /^context\.(.+)$/;
|
|
8
13
|
function extractCondition(expr, vars) {
|
|
14
|
+
if (!expr)
|
|
15
|
+
throw new Error("Missing condition expression");
|
|
9
16
|
const eqMatch = expr.match(EQ_RE);
|
|
10
17
|
if (eqMatch) {
|
|
11
18
|
vars.push({ path: eqMatch[1], type: "string" });
|
|
@@ -14,7 +21,9 @@ function extractCondition(expr, vars) {
|
|
|
14
21
|
const pathMatch = expr.match(PATH_RE);
|
|
15
22
|
if (pathMatch) {
|
|
16
23
|
vars.push({ path: pathMatch[1], type: "boolean" });
|
|
24
|
+
return;
|
|
17
25
|
}
|
|
26
|
+
throw new Error(`Invalid condition expression: ${expr}`);
|
|
18
27
|
}
|
|
19
28
|
function extractFromString(text, vars) {
|
|
20
29
|
for (const match of text.matchAll(IF_RE)) {
|
|
@@ -24,6 +33,48 @@ function extractFromString(text, vars) {
|
|
|
24
33
|
vars.push({ path: match[1], type: match[2] ?? "string" });
|
|
25
34
|
}
|
|
26
35
|
}
|
|
36
|
+
function validateIfBlocks(content, vars) {
|
|
37
|
+
const stack = [];
|
|
38
|
+
for (const match of content.matchAll(DIRECTIVE_RE)) {
|
|
39
|
+
const directive = match[1];
|
|
40
|
+
const condition = match[2];
|
|
41
|
+
if (directive === "if") {
|
|
42
|
+
extractCondition(condition, vars);
|
|
43
|
+
stack.push({ sawElse: false });
|
|
44
|
+
} else if (directive === "elseif") {
|
|
45
|
+
const frame = stack[stack.length - 1];
|
|
46
|
+
if (!frame)
|
|
47
|
+
throw new Error("Unexpected <@elseif> without <@if>");
|
|
48
|
+
if (frame.sawElse)
|
|
49
|
+
throw new Error("Unexpected <@elseif> after <@else>");
|
|
50
|
+
extractCondition(condition, vars);
|
|
51
|
+
} else if (directive === "else") {
|
|
52
|
+
const frame = stack[stack.length - 1];
|
|
53
|
+
if (!frame)
|
|
54
|
+
throw new Error("Unexpected <@else> without <@if>");
|
|
55
|
+
if (frame.sawElse)
|
|
56
|
+
throw new Error("Unexpected duplicate <@else>");
|
|
57
|
+
frame.sawElse = true;
|
|
58
|
+
} else if (directive === "endif") {
|
|
59
|
+
if (stack.length === 0)
|
|
60
|
+
throw new Error("Unexpected <@endif> without <@if>");
|
|
61
|
+
stack.pop();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (stack.length > 0) {
|
|
65
|
+
throw new Error("Unmatched <@if> without <@endif>");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function isUtf8Text(buffer) {
|
|
69
|
+
if (buffer.indexOf(0) !== -1)
|
|
70
|
+
return false;
|
|
71
|
+
try {
|
|
72
|
+
new TextDecoder("utf-8", { fatal: true }).decode(buffer);
|
|
73
|
+
return true;
|
|
74
|
+
} catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
27
78
|
function walkDir(dirPath, vars) {
|
|
28
79
|
const entries = readdirSync(dirPath).sort();
|
|
29
80
|
for (const entry of entries) {
|
|
@@ -33,8 +84,12 @@ function walkDir(dirPath, vars) {
|
|
|
33
84
|
if (stat.isDirectory()) {
|
|
34
85
|
walkDir(fullPath, vars);
|
|
35
86
|
} else if (stat.isFile()) {
|
|
36
|
-
const content = readFileSync(fullPath
|
|
37
|
-
|
|
87
|
+
const content = readFileSync(fullPath);
|
|
88
|
+
if (isUtf8Text(content)) {
|
|
89
|
+
const text = content.toString("utf-8");
|
|
90
|
+
extractFromString(text, vars);
|
|
91
|
+
validateIfBlocks(text, vars);
|
|
92
|
+
}
|
|
38
93
|
}
|
|
39
94
|
}
|
|
40
95
|
}
|
|
@@ -52,25 +107,37 @@ function parse(dirPath) {
|
|
|
52
107
|
}
|
|
53
108
|
|
|
54
109
|
// render.ts
|
|
55
|
-
import {
|
|
56
|
-
|
|
110
|
+
import {
|
|
111
|
+
copyFileSync,
|
|
112
|
+
mkdirSync,
|
|
113
|
+
readFileSync as readFileSync2,
|
|
114
|
+
readdirSync as readdirSync2,
|
|
115
|
+
rmSync,
|
|
116
|
+
statSync as statSync2,
|
|
117
|
+
writeFileSync
|
|
118
|
+
} from "node:fs";
|
|
119
|
+
import { dirname, isAbsolute, relative, resolve as resolvePath } from "node:path";
|
|
120
|
+
import { homedir } from "node:os";
|
|
121
|
+
import { TextDecoder as TextDecoder2 } from "node:util";
|
|
57
122
|
var IF_PATH_RE = /^<@if\((.+?)\)>(.*)$/;
|
|
58
123
|
var VAR_RE2 = /<@var\(context\.(.+?)(?::(\w+))?\)>/g;
|
|
59
|
-
var
|
|
124
|
+
var DIRECTIVE_RE2 = /<@(if|elseif|else|endif)(?:\((.+?)\))?>/g;
|
|
60
125
|
var EQ_RE2 = /^eq\(context\.(.+?),\s*"(.*)"\)$/;
|
|
61
126
|
var PATH_RE2 = /^context\.(.+)$/;
|
|
62
127
|
function evalCondition(expr, context) {
|
|
128
|
+
if (!expr)
|
|
129
|
+
throw new Error("Missing condition expression");
|
|
63
130
|
const eqMatch = expr.match(EQ_RE2);
|
|
64
131
|
if (eqMatch) {
|
|
65
|
-
return
|
|
132
|
+
return resolveContext(context, eqMatch[1]) === eqMatch[2];
|
|
66
133
|
}
|
|
67
134
|
const pathMatch = expr.match(PATH_RE2);
|
|
68
135
|
if (pathMatch) {
|
|
69
|
-
return !!
|
|
136
|
+
return !!resolveContext(context, pathMatch[1]);
|
|
70
137
|
}
|
|
71
138
|
throw new Error(`Invalid condition expression: ${expr}`);
|
|
72
139
|
}
|
|
73
|
-
function
|
|
140
|
+
function resolveContext(context, path) {
|
|
74
141
|
const segments = path.split(".");
|
|
75
142
|
let current = context;
|
|
76
143
|
for (const seg of segments) {
|
|
@@ -80,107 +147,580 @@ function resolve(context, path) {
|
|
|
80
147
|
}
|
|
81
148
|
return current;
|
|
82
149
|
}
|
|
83
|
-
function
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
150
|
+
function isInsidePath(parent, child) {
|
|
151
|
+
const rel = relative(parent, child);
|
|
152
|
+
return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
|
|
153
|
+
}
|
|
154
|
+
function assertSafeRenderTarget(srcDir, destDir) {
|
|
155
|
+
const sourceRoot = resolvePath(srcDir);
|
|
156
|
+
const destRoot = resolvePath(destDir);
|
|
157
|
+
if (destRoot === resolvePath(destRoot, "..")) {
|
|
158
|
+
throw new Error("Refusing to render into filesystem root");
|
|
91
159
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
160
|
+
if (destRoot === resolvePath(process.cwd())) {
|
|
161
|
+
throw new Error("Refusing to render into the current working directory");
|
|
162
|
+
}
|
|
163
|
+
if (destRoot === resolvePath(homedir())) {
|
|
164
|
+
throw new Error("Refusing to render into the home directory");
|
|
165
|
+
}
|
|
166
|
+
if (isInsidePath(sourceRoot, destRoot) || isInsidePath(destRoot, sourceRoot)) {
|
|
167
|
+
throw new Error("Refusing to render when source and target directories overlap");
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function resolveOutputPath(destRoot, outputName) {
|
|
171
|
+
const outputPath = resolvePath(destRoot, outputName);
|
|
172
|
+
if (!isInsidePath(destRoot, outputPath)) {
|
|
173
|
+
throw new Error(`Refusing to write outside target directory: ${outputName}`);
|
|
174
|
+
}
|
|
175
|
+
return outputPath;
|
|
176
|
+
}
|
|
177
|
+
function createReverseMapManifest() {
|
|
178
|
+
return { version: 1, files: [], skipped: [], tokens: {} };
|
|
179
|
+
}
|
|
180
|
+
function addReverseMapToken(state, file, token) {
|
|
181
|
+
if (!state?.manifest || !file)
|
|
182
|
+
return;
|
|
183
|
+
file.tokens.push(token);
|
|
184
|
+
const tokens = state.manifest.tokens[token.result] ?? [];
|
|
185
|
+
if (!tokens.includes(token.token))
|
|
186
|
+
tokens.push(token.token);
|
|
187
|
+
state.manifest.tokens[token.result] = tokens;
|
|
188
|
+
}
|
|
189
|
+
function getReverseMapPath(destRoot, reverseMap) {
|
|
190
|
+
if (reverseMap === true)
|
|
191
|
+
return resolveOutputPath(destRoot, ".tdir-map.json");
|
|
192
|
+
return resolveOutputPath(destRoot, reverseMap);
|
|
193
|
+
}
|
|
194
|
+
function getOutputName(entry, context, state, file) {
|
|
195
|
+
const ifMatch = entry.match(IF_PATH_RE);
|
|
196
|
+
let outputName = entry;
|
|
197
|
+
if (ifMatch) {
|
|
198
|
+
if (!evalCondition(ifMatch[1], context))
|
|
199
|
+
return null;
|
|
200
|
+
outputName = ifMatch[2];
|
|
201
|
+
if (ifMatch[0] !== outputName) {
|
|
202
|
+
addReverseMapToken(state, file, {
|
|
203
|
+
kind: "path",
|
|
204
|
+
result: outputName,
|
|
205
|
+
token: ifMatch[0],
|
|
206
|
+
outputPath: file?.outputPath ?? "",
|
|
207
|
+
templatePath: file?.templatePath ?? ""
|
|
208
|
+
});
|
|
131
209
|
}
|
|
132
210
|
}
|
|
133
|
-
|
|
134
|
-
|
|
211
|
+
return outputName.replace(VAR_RE2, (_match, path) => {
|
|
212
|
+
const result = String(resolveContext(context, path) ?? "");
|
|
213
|
+
addReverseMapToken(state, file, {
|
|
214
|
+
kind: "path",
|
|
215
|
+
result,
|
|
216
|
+
token: _match,
|
|
217
|
+
contextPath: path,
|
|
218
|
+
outputPath: file?.outputPath ?? "",
|
|
219
|
+
templatePath: file?.templatePath ?? ""
|
|
220
|
+
});
|
|
221
|
+
return result;
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
function isUtf8Text2(buffer) {
|
|
225
|
+
if (buffer.indexOf(0) !== -1)
|
|
226
|
+
return false;
|
|
227
|
+
try {
|
|
228
|
+
new TextDecoder2("utf-8", { fatal: true }).decode(buffer);
|
|
229
|
+
return true;
|
|
230
|
+
} catch {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
function getDirectiveTokens(content) {
|
|
235
|
+
return Array.from(content.matchAll(DIRECTIVE_RE2), (match) => ({
|
|
236
|
+
type: match[1],
|
|
237
|
+
condition: match[2],
|
|
238
|
+
index: match.index,
|
|
239
|
+
end: match.index + match[0].length
|
|
240
|
+
}));
|
|
241
|
+
}
|
|
242
|
+
function parseNodes(content, tokens, tokenIndex, pos, stopTypes) {
|
|
243
|
+
const nodes = [];
|
|
244
|
+
while (tokenIndex < tokens.length) {
|
|
245
|
+
const token = tokens[tokenIndex];
|
|
246
|
+
if (stopTypes.includes(token.type)) {
|
|
247
|
+
if (token.index > pos)
|
|
248
|
+
nodes.push({ type: "text", text: content.slice(pos, token.index) });
|
|
249
|
+
return { nodes, pos: token.index, tokenIndex, stop: token };
|
|
250
|
+
}
|
|
251
|
+
if (token.type !== "if") {
|
|
252
|
+
throw new Error(`Unexpected <@${token.type}> without <@if>`);
|
|
253
|
+
}
|
|
254
|
+
if (token.index > pos)
|
|
255
|
+
nodes.push({ type: "text", text: content.slice(pos, token.index) });
|
|
256
|
+
const parsed = parseIfNode(content, tokens, tokenIndex);
|
|
257
|
+
nodes.push(parsed.node);
|
|
258
|
+
tokenIndex = parsed.tokenIndex;
|
|
259
|
+
pos = parsed.pos;
|
|
260
|
+
}
|
|
261
|
+
if (pos < content.length)
|
|
262
|
+
nodes.push({ type: "text", text: content.slice(pos) });
|
|
263
|
+
return { nodes, pos: content.length, tokenIndex };
|
|
264
|
+
}
|
|
265
|
+
function parseIfNode(content, tokens, tokenIndex) {
|
|
266
|
+
const firstToken = tokens[tokenIndex];
|
|
267
|
+
const sourceStart = firstToken.index;
|
|
268
|
+
const branches = [];
|
|
269
|
+
let branchType = "if";
|
|
270
|
+
let branchCondition = firstToken.condition;
|
|
271
|
+
let branchContentStart = firstToken.end;
|
|
272
|
+
tokenIndex += 1;
|
|
273
|
+
while (true) {
|
|
274
|
+
const parsed = parseNodes(content, tokens, tokenIndex, branchContentStart, ["elseif", "else", "endif"]);
|
|
275
|
+
if (!parsed.stop)
|
|
276
|
+
throw new Error("Unmatched <@if> without <@endif>");
|
|
277
|
+
branches.push({
|
|
278
|
+
type: branchType,
|
|
279
|
+
condition: branchCondition,
|
|
280
|
+
nodes: parsed.nodes,
|
|
281
|
+
contentStart: branchContentStart,
|
|
282
|
+
contentEnd: parsed.pos
|
|
283
|
+
});
|
|
284
|
+
if (parsed.stop.type === "endif") {
|
|
285
|
+
const sourceEnd = parsed.stop.end;
|
|
286
|
+
return {
|
|
287
|
+
node: {
|
|
288
|
+
type: "if",
|
|
289
|
+
sourceStart,
|
|
290
|
+
sourceEnd,
|
|
291
|
+
source: content.slice(sourceStart, sourceEnd),
|
|
292
|
+
branches
|
|
293
|
+
},
|
|
294
|
+
pos: sourceEnd,
|
|
295
|
+
tokenIndex: parsed.tokenIndex + 1
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
branchType = parsed.stop.type;
|
|
299
|
+
branchCondition = parsed.stop.condition;
|
|
300
|
+
branchContentStart = parsed.stop.end;
|
|
301
|
+
tokenIndex = parsed.tokenIndex + 1;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
function getActiveBranch(node, context) {
|
|
305
|
+
for (const branch of node.branches) {
|
|
306
|
+
if (branch.type === "else" || evalCondition(branch.condition, context))
|
|
307
|
+
return branch;
|
|
308
|
+
}
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
function renderNodes(nodes, context, conditionalTokens, outputStart = 0) {
|
|
312
|
+
let result = "";
|
|
313
|
+
for (const node of nodes) {
|
|
314
|
+
if (node.type === "text") {
|
|
315
|
+
result += node.text;
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
const activeBranch = getActiveBranch(node, context);
|
|
319
|
+
const start = outputStart + result.length;
|
|
320
|
+
const renderedBranch = activeBranch ? renderNodes(activeBranch.nodes, context, conditionalTokens, start) : "";
|
|
321
|
+
result += renderedBranch;
|
|
322
|
+
conditionalTokens.push({
|
|
323
|
+
result: renderedBranch,
|
|
324
|
+
token: node.source,
|
|
325
|
+
range: { start, end: start + renderedBranch.length },
|
|
326
|
+
activeRange: activeBranch ? {
|
|
327
|
+
start: activeBranch.contentStart - node.sourceStart,
|
|
328
|
+
end: activeBranch.contentEnd - node.sourceStart
|
|
329
|
+
} : { start: node.source.length, end: node.source.length }
|
|
330
|
+
});
|
|
135
331
|
}
|
|
136
|
-
result += content.slice(pos);
|
|
137
332
|
return result;
|
|
138
333
|
}
|
|
139
|
-
function
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
334
|
+
function processIfBlocksWithMap(content, context) {
|
|
335
|
+
const tokens = getDirectiveTokens(content);
|
|
336
|
+
const parsed = parseNodes(content, tokens, 0, 0, []);
|
|
337
|
+
const conditionalTokens = [];
|
|
338
|
+
return {
|
|
339
|
+
content: renderNodes(parsed.nodes, context, conditionalTokens),
|
|
340
|
+
conditionalTokens
|
|
341
|
+
};
|
|
144
342
|
}
|
|
145
|
-
function
|
|
343
|
+
function renderContentWithMap(content, context, state, file) {
|
|
344
|
+
const processedResult = processIfBlocksWithMap(content, context);
|
|
345
|
+
const processed = processedResult.content;
|
|
346
|
+
for (const token of processedResult.conditionalTokens) {
|
|
347
|
+
addReverseMapToken(state, file, {
|
|
348
|
+
kind: "conditional",
|
|
349
|
+
result: token.result,
|
|
350
|
+
token: token.token,
|
|
351
|
+
outputPath: file?.outputPath ?? "",
|
|
352
|
+
templatePath: file?.templatePath ?? "",
|
|
353
|
+
range: token.range,
|
|
354
|
+
activeRange: token.activeRange,
|
|
355
|
+
before: processed.slice(0, token.range.start),
|
|
356
|
+
after: processed.slice(token.range.end)
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
let result = "";
|
|
360
|
+
let pos = 0;
|
|
361
|
+
for (const match of processed.matchAll(VAR_RE2)) {
|
|
362
|
+
const token = match[0];
|
|
363
|
+
const path = match[1];
|
|
364
|
+
const rendered = String(resolveContext(context, path) ?? "");
|
|
365
|
+
result += processed.slice(pos, match.index);
|
|
366
|
+
const start = result.length;
|
|
367
|
+
result += rendered;
|
|
368
|
+
addReverseMapToken(state, file, {
|
|
369
|
+
kind: "content",
|
|
370
|
+
result: rendered,
|
|
371
|
+
token,
|
|
372
|
+
contextPath: path,
|
|
373
|
+
outputPath: file?.outputPath ?? "",
|
|
374
|
+
templatePath: file?.templatePath ?? "",
|
|
375
|
+
range: { start, end: start + rendered.length }
|
|
376
|
+
});
|
|
377
|
+
pos = match.index + token.length;
|
|
378
|
+
}
|
|
379
|
+
result += processed.slice(pos);
|
|
380
|
+
return result;
|
|
381
|
+
}
|
|
382
|
+
function renderDir(srcDir, destDir, context, options = {}) {
|
|
383
|
+
const destRoot = resolvePath(destDir);
|
|
384
|
+
const sourceRoot = resolvePath(srcDir);
|
|
385
|
+
const state = {
|
|
386
|
+
sourceRoot,
|
|
387
|
+
destRoot,
|
|
388
|
+
manifest: options.reverseMap ? createReverseMapManifest() : undefined
|
|
389
|
+
};
|
|
390
|
+
const mapPath = options.reverseMap ? getReverseMapPath(destRoot, options.reverseMap) : undefined;
|
|
391
|
+
assertSafeRenderTarget(srcDir, destDir);
|
|
392
|
+
validateOutputPaths(srcDir, destRoot, context);
|
|
146
393
|
rmSync(destDir, { recursive: true, force: true });
|
|
147
|
-
renderDirInner(srcDir,
|
|
394
|
+
renderDirInner(srcDir, destRoot, context, state);
|
|
395
|
+
if (state.manifest && mapPath) {
|
|
396
|
+
mkdirSync(dirname(mapPath), { recursive: true });
|
|
397
|
+
writeFileSync(mapPath, `${JSON.stringify(state.manifest, null, 2)}
|
|
398
|
+
`);
|
|
399
|
+
}
|
|
148
400
|
}
|
|
149
|
-
function
|
|
150
|
-
mkdirSync(destDir, { recursive: true });
|
|
401
|
+
function validateOutputPaths(srcDir, destDir, context) {
|
|
151
402
|
const entries = readdirSync2(srcDir).sort();
|
|
152
403
|
for (const entry of entries) {
|
|
153
|
-
const srcPath =
|
|
404
|
+
const srcPath = resolvePath(srcDir, entry);
|
|
154
405
|
const stat = statSync2(srcPath);
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
|
|
406
|
+
const outputName = getOutputName(entry, context);
|
|
407
|
+
if (outputName === null)
|
|
408
|
+
continue;
|
|
409
|
+
const destPath = resolveOutputPath(destDir, outputName);
|
|
410
|
+
if (stat.isDirectory()) {
|
|
411
|
+
validateOutputPaths(srcPath, destPath, context);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
function storeSkippedTemplate(srcPath, state) {
|
|
416
|
+
if (!state.manifest)
|
|
417
|
+
return;
|
|
418
|
+
const stat = statSync2(srcPath);
|
|
419
|
+
const templatePath = relative(state.sourceRoot, srcPath);
|
|
420
|
+
if (stat.isDirectory()) {
|
|
421
|
+
state.manifest.skipped.push({ kind: "directory", templatePath });
|
|
422
|
+
for (const entry of readdirSync2(srcPath).sort()) {
|
|
423
|
+
storeSkippedTemplate(resolvePath(srcPath, entry), state);
|
|
424
|
+
}
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
if (!stat.isFile())
|
|
428
|
+
return;
|
|
429
|
+
const content = readFileSync2(srcPath);
|
|
430
|
+
if (isUtf8Text2(content)) {
|
|
431
|
+
state.manifest.skipped.push({
|
|
432
|
+
kind: "file",
|
|
433
|
+
templatePath,
|
|
434
|
+
encoding: "utf8",
|
|
435
|
+
content: content.toString("utf-8")
|
|
436
|
+
});
|
|
437
|
+
} else {
|
|
438
|
+
state.manifest.skipped.push({
|
|
439
|
+
kind: "file",
|
|
440
|
+
templatePath,
|
|
441
|
+
encoding: "base64",
|
|
442
|
+
content: content.toString("base64")
|
|
164
443
|
});
|
|
165
|
-
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
function renderDirInner(srcDir, destDir, context, state) {
|
|
447
|
+
mkdirSync(destDir, { recursive: true });
|
|
448
|
+
const entries = readdirSync2(srcDir).sort();
|
|
449
|
+
for (const entry of entries) {
|
|
450
|
+
const srcPath = resolvePath(srcDir, entry);
|
|
451
|
+
const stat = statSync2(srcPath);
|
|
452
|
+
const templatePath = relative(state.sourceRoot, srcPath);
|
|
453
|
+
const tempFile = {
|
|
454
|
+
outputPath: "",
|
|
455
|
+
templatePath,
|
|
456
|
+
tokens: []
|
|
457
|
+
};
|
|
458
|
+
const outputName = getOutputName(entry, context, state, tempFile);
|
|
459
|
+
if (outputName === null) {
|
|
460
|
+
storeSkippedTemplate(srcPath, state);
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
const destPath = resolveOutputPath(destDir, outputName);
|
|
464
|
+
const outputPath = relative(state.destRoot, destPath);
|
|
465
|
+
tempFile.outputPath = outputPath;
|
|
466
|
+
for (const token of tempFile.tokens) {
|
|
467
|
+
token.outputPath = outputPath;
|
|
468
|
+
token.templatePath = templatePath;
|
|
469
|
+
}
|
|
166
470
|
if (stat.isDirectory()) {
|
|
167
|
-
|
|
471
|
+
if (tempFile.tokens.length > 0)
|
|
472
|
+
state.manifest?.files.push(tempFile);
|
|
473
|
+
renderDirInner(srcPath, destPath, context, state);
|
|
168
474
|
} else {
|
|
169
|
-
mkdirSync(
|
|
170
|
-
const content = readFileSync2(srcPath
|
|
171
|
-
|
|
475
|
+
mkdirSync(dirname(destPath), { recursive: true });
|
|
476
|
+
const content = readFileSync2(srcPath);
|
|
477
|
+
if (isUtf8Text2(content)) {
|
|
478
|
+
const rendered = renderContentWithMap(content.toString("utf-8"), context, state, tempFile);
|
|
479
|
+
writeFileSync(destPath, rendered);
|
|
480
|
+
} else {
|
|
481
|
+
copyFileSync(srcPath, destPath);
|
|
482
|
+
}
|
|
483
|
+
if (state.manifest)
|
|
484
|
+
state.manifest.files.push(tempFile);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// reverse.ts
|
|
490
|
+
import {
|
|
491
|
+
copyFileSync as copyFileSync2,
|
|
492
|
+
existsSync,
|
|
493
|
+
mkdirSync as mkdirSync2,
|
|
494
|
+
readFileSync as readFileSync3,
|
|
495
|
+
statSync as statSync3,
|
|
496
|
+
writeFileSync as writeFileSync2
|
|
497
|
+
} from "node:fs";
|
|
498
|
+
import { dirname as dirname2, isAbsolute as isAbsolute2, relative as relative2, resolve as resolvePath2 } from "node:path";
|
|
499
|
+
import { TextDecoder as TextDecoder3 } from "node:util";
|
|
500
|
+
function isInsidePath2(parent, child) {
|
|
501
|
+
const rel = relative2(parent, child);
|
|
502
|
+
return rel === "" || !rel.startsWith("..") && !isAbsolute2(rel);
|
|
503
|
+
}
|
|
504
|
+
function resolveInside(root, path) {
|
|
505
|
+
const resolved = resolvePath2(root, path);
|
|
506
|
+
if (!isInsidePath2(root, resolved)) {
|
|
507
|
+
throw new Error(`Refusing to write outside target directory: ${path}`);
|
|
508
|
+
}
|
|
509
|
+
return resolved;
|
|
510
|
+
}
|
|
511
|
+
function isUtf8Text3(buffer) {
|
|
512
|
+
if (buffer.indexOf(0) !== -1)
|
|
513
|
+
return false;
|
|
514
|
+
try {
|
|
515
|
+
new TextDecoder3("utf-8", { fatal: true }).decode(buffer);
|
|
516
|
+
return true;
|
|
517
|
+
} catch {
|
|
518
|
+
return false;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
function readManifest(mapPath) {
|
|
522
|
+
const manifest = JSON.parse(readFileSync3(mapPath, "utf-8"));
|
|
523
|
+
if (manifest.version !== 1 || !Array.isArray(manifest.files)) {
|
|
524
|
+
throw new Error(`Unsupported reverse map: ${mapPath}`);
|
|
525
|
+
}
|
|
526
|
+
return manifest;
|
|
527
|
+
}
|
|
528
|
+
function replaceAtRange(content, token) {
|
|
529
|
+
if (!token.range)
|
|
530
|
+
return null;
|
|
531
|
+
const { start, end } = token.range;
|
|
532
|
+
if (start < 0 || end < start || end > content.length)
|
|
533
|
+
return null;
|
|
534
|
+
if (content.slice(start, end) !== token.result)
|
|
535
|
+
return null;
|
|
536
|
+
return `${content.slice(0, start)}${token.token}${content.slice(end)}`;
|
|
537
|
+
}
|
|
538
|
+
function replaceFirst(content, token) {
|
|
539
|
+
const index = content.indexOf(token.result);
|
|
540
|
+
if (index === -1)
|
|
541
|
+
return null;
|
|
542
|
+
return `${content.slice(0, index)}${token.token}${content.slice(index + token.result.length)}`;
|
|
543
|
+
}
|
|
544
|
+
function applyActiveBranch(token, branchContent) {
|
|
545
|
+
if (!token.activeRange)
|
|
546
|
+
return token.token;
|
|
547
|
+
return [
|
|
548
|
+
token.token.slice(0, token.activeRange.start),
|
|
549
|
+
branchContent,
|
|
550
|
+
token.token.slice(token.activeRange.end)
|
|
551
|
+
].join("");
|
|
552
|
+
}
|
|
553
|
+
function findPrefixEnd(content, before) {
|
|
554
|
+
if (before === "")
|
|
555
|
+
return 0;
|
|
556
|
+
let candidate = before;
|
|
557
|
+
while (candidate.length >= 8) {
|
|
558
|
+
const index = content.indexOf(candidate);
|
|
559
|
+
if (index !== -1)
|
|
560
|
+
return index + candidate.length;
|
|
561
|
+
candidate = candidate.slice(-Math.max(1, Math.floor(candidate.length / 2)));
|
|
562
|
+
}
|
|
563
|
+
return -1;
|
|
564
|
+
}
|
|
565
|
+
function findSuffixStart(content, after, from) {
|
|
566
|
+
if (after === "")
|
|
567
|
+
return content.length;
|
|
568
|
+
let candidate = after;
|
|
569
|
+
while (candidate.length >= 8) {
|
|
570
|
+
const index = content.indexOf(candidate, from);
|
|
571
|
+
if (index !== -1)
|
|
572
|
+
return index;
|
|
573
|
+
candidate = candidate.slice(0, Math.floor(candidate.length / 2));
|
|
574
|
+
}
|
|
575
|
+
return -1;
|
|
576
|
+
}
|
|
577
|
+
function replaceConditional(content, token) {
|
|
578
|
+
const exactIndex = content.indexOf(token.result);
|
|
579
|
+
if (exactIndex !== -1) {
|
|
580
|
+
return [
|
|
581
|
+
content.slice(0, exactIndex),
|
|
582
|
+
token.token,
|
|
583
|
+
content.slice(exactIndex + token.result.length)
|
|
584
|
+
].join("");
|
|
585
|
+
}
|
|
586
|
+
if (token.before === undefined || token.after === undefined)
|
|
587
|
+
return null;
|
|
588
|
+
const branchStart = findPrefixEnd(content, token.before);
|
|
589
|
+
if (branchStart === -1)
|
|
590
|
+
return null;
|
|
591
|
+
const afterIndex = findSuffixStart(content, token.after, branchStart);
|
|
592
|
+
if (afterIndex === -1)
|
|
593
|
+
return null;
|
|
594
|
+
return [
|
|
595
|
+
content.slice(0, branchStart),
|
|
596
|
+
applyActiveBranch(token, content.slice(branchStart, afterIndex)),
|
|
597
|
+
content.slice(afterIndex)
|
|
598
|
+
].join("");
|
|
599
|
+
}
|
|
600
|
+
function reverseContent(content, file, warnings) {
|
|
601
|
+
const contentTokens = file.tokens.filter((token) => token.kind === "content").sort((a, b) => (b.range?.start ?? -1) - (a.range?.start ?? -1));
|
|
602
|
+
let reversed = content;
|
|
603
|
+
for (const token of contentTokens) {
|
|
604
|
+
const rangeResult = replaceAtRange(reversed, token);
|
|
605
|
+
if (rangeResult !== null) {
|
|
606
|
+
reversed = rangeResult;
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
const fallbackResult = replaceFirst(reversed, token);
|
|
610
|
+
if (fallbackResult !== null) {
|
|
611
|
+
reversed = fallbackResult;
|
|
612
|
+
continue;
|
|
172
613
|
}
|
|
614
|
+
warnings.push({
|
|
615
|
+
outputPath: file.outputPath,
|
|
616
|
+
token: token.token,
|
|
617
|
+
result: token.result,
|
|
618
|
+
message: "Rendered value was not found; token was not restored"
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
const conditionalTokens = file.tokens.filter((token) => token.kind === "conditional").sort((a, b) => (b.range?.start ?? -1) - (a.range?.start ?? -1));
|
|
622
|
+
for (const token of conditionalTokens) {
|
|
623
|
+
const result = replaceConditional(reversed, token);
|
|
624
|
+
if (result !== null) {
|
|
625
|
+
reversed = result;
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
warnings.push({
|
|
629
|
+
outputPath: file.outputPath,
|
|
630
|
+
token: token.token,
|
|
631
|
+
result: token.result,
|
|
632
|
+
message: "Rendered conditional block was not found; block was not restored"
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
return reversed;
|
|
636
|
+
}
|
|
637
|
+
function writeSkippedTemplate(templateRoot, skipped) {
|
|
638
|
+
const templatePath = resolveInside(templateRoot, skipped.templatePath);
|
|
639
|
+
if (skipped.kind === "directory") {
|
|
640
|
+
mkdirSync2(templatePath, { recursive: true });
|
|
641
|
+
return 0;
|
|
173
642
|
}
|
|
643
|
+
mkdirSync2(dirname2(templatePath), { recursive: true });
|
|
644
|
+
const content = skipped.encoding === "base64" ? Buffer.from(skipped.content ?? "", "base64") : skipped.content ?? "";
|
|
645
|
+
writeFileSync2(templatePath, content);
|
|
646
|
+
return 1;
|
|
647
|
+
}
|
|
648
|
+
function reverseDir(renderedDir, templateDir, options = {}) {
|
|
649
|
+
const renderedRoot = resolvePath2(renderedDir);
|
|
650
|
+
const templateRoot = resolvePath2(templateDir);
|
|
651
|
+
const mapPath = options.mapPath ? resolvePath2(renderedRoot, options.mapPath) : resolvePath2(renderedRoot, ".tdir-map.json");
|
|
652
|
+
const manifest = readManifest(mapPath);
|
|
653
|
+
const warnings = [];
|
|
654
|
+
let filesWritten = 0;
|
|
655
|
+
for (const file of manifest.files) {
|
|
656
|
+
const renderedPath = resolveInside(renderedRoot, file.outputPath);
|
|
657
|
+
const templatePath = resolveInside(templateRoot, file.templatePath);
|
|
658
|
+
if (!existsSync(renderedPath)) {
|
|
659
|
+
warnings.push({
|
|
660
|
+
outputPath: file.outputPath,
|
|
661
|
+
token: "",
|
|
662
|
+
result: "",
|
|
663
|
+
message: "Rendered path does not exist; skipped"
|
|
664
|
+
});
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
const stat = statSync3(renderedPath);
|
|
668
|
+
if (stat.isDirectory()) {
|
|
669
|
+
mkdirSync2(templatePath, { recursive: true });
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
if (!stat.isFile())
|
|
673
|
+
continue;
|
|
674
|
+
mkdirSync2(dirname2(templatePath), { recursive: true });
|
|
675
|
+
const content = readFileSync3(renderedPath);
|
|
676
|
+
if (!isUtf8Text3(content)) {
|
|
677
|
+
copyFileSync2(renderedPath, templatePath);
|
|
678
|
+
filesWritten += 1;
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
writeFileSync2(templatePath, reverseContent(content.toString("utf-8"), file, warnings));
|
|
682
|
+
filesWritten += 1;
|
|
683
|
+
}
|
|
684
|
+
for (const skipped of manifest.skipped ?? []) {
|
|
685
|
+
filesWritten += writeSkippedTemplate(templateRoot, skipped);
|
|
686
|
+
}
|
|
687
|
+
return { filesWritten, warnings };
|
|
174
688
|
}
|
|
175
689
|
|
|
176
690
|
// index.ts
|
|
177
691
|
class SchemaMismatchError extends Error {
|
|
178
692
|
constructor(issue) {
|
|
179
|
-
super(`
|
|
693
|
+
super(`Schema doesn't match used template variables: ${issue.path}: ${issue.message}`);
|
|
694
|
+
this.name = "SchemaMismatchError";
|
|
180
695
|
}
|
|
181
696
|
}
|
|
697
|
+
function zodTypeName(schema) {
|
|
698
|
+
if (schema instanceof z.ZodObject)
|
|
699
|
+
return "object";
|
|
700
|
+
if (schema instanceof z.ZodString)
|
|
701
|
+
return "string";
|
|
702
|
+
if (schema instanceof z.ZodBoolean)
|
|
703
|
+
return "boolean";
|
|
704
|
+
if (schema instanceof z.ZodNumber)
|
|
705
|
+
return "number";
|
|
706
|
+
return schema.constructor.name;
|
|
707
|
+
}
|
|
708
|
+
function zodDisplayName(schema) {
|
|
709
|
+
const type = zodTypeName(schema);
|
|
710
|
+
return type.startsWith("Zod") ? type : `z.${type}()`;
|
|
711
|
+
}
|
|
712
|
+
function setExpectedType(expected, path, type) {
|
|
713
|
+
const existing = expected.get(path);
|
|
714
|
+
if (existing && existing !== type) {
|
|
715
|
+
throw new SchemaMismatchError({
|
|
716
|
+
path,
|
|
717
|
+
message: `conflicting template variable types: expected both z.${existing}() and z.${type}()`
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
expected.set(path, type);
|
|
721
|
+
}
|
|
182
722
|
function validateSchemaMatchesTemplates(userSchema, variables) {
|
|
183
|
-
if (userSchema
|
|
723
|
+
if (!(userSchema instanceof z.ZodObject)) {
|
|
184
724
|
throw new SchemaMismatchError({ path: "", message: "Schema must be a z.object()" });
|
|
185
725
|
}
|
|
186
726
|
const expected = new Map;
|
|
@@ -188,11 +728,9 @@ function validateSchemaMatchesTemplates(userSchema, variables) {
|
|
|
188
728
|
const segments = v.path.split(".");
|
|
189
729
|
for (let i = 0;i < segments.length - 1; i++) {
|
|
190
730
|
const intermediate = segments.slice(0, i + 1).join(".");
|
|
191
|
-
|
|
192
|
-
expected.set(intermediate, "object");
|
|
193
|
-
}
|
|
731
|
+
setExpectedType(expected, intermediate, "object");
|
|
194
732
|
}
|
|
195
|
-
expected
|
|
733
|
+
setExpectedType(expected, v.path, v.type);
|
|
196
734
|
}
|
|
197
735
|
for (const [path, expectedType] of expected) {
|
|
198
736
|
const segments = path.split(".");
|
|
@@ -200,8 +738,8 @@ function validateSchemaMatchesTemplates(userSchema, variables) {
|
|
|
200
738
|
let currentPath = "";
|
|
201
739
|
for (const seg of segments) {
|
|
202
740
|
currentPath = currentPath ? `${currentPath}.${seg}` : seg;
|
|
203
|
-
if (current
|
|
204
|
-
throw new SchemaMismatchError({ path: currentPath, message: `expected z.object() but schema has
|
|
741
|
+
if (!(current instanceof z.ZodObject)) {
|
|
742
|
+
throw new SchemaMismatchError({ path: currentPath, message: `expected z.object() but schema has ${zodDisplayName(current)}` });
|
|
205
743
|
}
|
|
206
744
|
const shape = current.shape;
|
|
207
745
|
if (!(seg in shape)) {
|
|
@@ -209,9 +747,9 @@ function validateSchemaMatchesTemplates(userSchema, variables) {
|
|
|
209
747
|
}
|
|
210
748
|
current = shape[seg];
|
|
211
749
|
}
|
|
212
|
-
const actual = current
|
|
750
|
+
const actual = zodTypeName(current);
|
|
213
751
|
if (actual !== expectedType) {
|
|
214
|
-
throw new SchemaMismatchError({ path, message: `expected z.${expectedType}() but schema has
|
|
752
|
+
throw new SchemaMismatchError({ path, message: `expected z.${expectedType}() but schema has ${zodDisplayName(current)}` });
|
|
215
753
|
}
|
|
216
754
|
}
|
|
217
755
|
}
|
|
@@ -219,14 +757,15 @@ var initRenderer = (dirPath) => {
|
|
|
219
757
|
const variables = parse(dirPath);
|
|
220
758
|
const createRenderer = (userSchema) => {
|
|
221
759
|
validateSchemaMatchesTemplates(userSchema, variables);
|
|
222
|
-
return (targetPath, context) => {
|
|
760
|
+
return (targetPath, context, options) => {
|
|
223
761
|
userSchema.parse(context);
|
|
224
|
-
renderDir(dirPath, targetPath, context);
|
|
762
|
+
renderDir(dirPath, targetPath, context, options);
|
|
225
763
|
};
|
|
226
764
|
};
|
|
227
765
|
return createRenderer;
|
|
228
766
|
};
|
|
229
767
|
export {
|
|
768
|
+
reverseDir,
|
|
230
769
|
initRenderer,
|
|
231
770
|
SchemaMismatchError
|
|
232
771
|
};
|
package/dist/render.d.ts
CHANGED
|
@@ -1,3 +1,41 @@
|
|
|
1
|
+
export type ReverseMapToken = {
|
|
2
|
+
kind: "path" | "content" | "conditional";
|
|
3
|
+
result: string;
|
|
4
|
+
token: string;
|
|
5
|
+
contextPath?: string;
|
|
6
|
+
outputPath: string;
|
|
7
|
+
templatePath: string;
|
|
8
|
+
range?: {
|
|
9
|
+
start: number;
|
|
10
|
+
end: number;
|
|
11
|
+
};
|
|
12
|
+
activeRange?: {
|
|
13
|
+
start: number;
|
|
14
|
+
end: number;
|
|
15
|
+
};
|
|
16
|
+
before?: string;
|
|
17
|
+
after?: string;
|
|
18
|
+
};
|
|
19
|
+
export type ReverseMapFile = {
|
|
20
|
+
outputPath: string;
|
|
21
|
+
templatePath: string;
|
|
22
|
+
tokens: ReverseMapToken[];
|
|
23
|
+
};
|
|
24
|
+
export type ReverseMapStoredTemplate = {
|
|
25
|
+
kind: "directory" | "file";
|
|
26
|
+
templatePath: string;
|
|
27
|
+
encoding?: "utf8" | "base64";
|
|
28
|
+
content?: string;
|
|
29
|
+
};
|
|
30
|
+
export type ReverseMapManifest = {
|
|
31
|
+
version: 1;
|
|
32
|
+
files: ReverseMapFile[];
|
|
33
|
+
skipped: ReverseMapStoredTemplate[];
|
|
34
|
+
tokens: Record<string, string[]>;
|
|
35
|
+
};
|
|
36
|
+
export type RenderOptions = {
|
|
37
|
+
reverseMap?: boolean | string;
|
|
38
|
+
};
|
|
1
39
|
declare function renderContent(content: string, context: Record<string, unknown>): string;
|
|
2
|
-
declare function renderDir(srcDir: string, destDir: string, context: Record<string, unknown
|
|
40
|
+
declare function renderDir(srcDir: string, destDir: string, context: Record<string, unknown>, options?: RenderOptions): void;
|
|
3
41
|
export { renderDir, renderContent };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type ReverseOptions = {
|
|
2
|
+
mapPath?: string;
|
|
3
|
+
};
|
|
4
|
+
export type ReverseWarning = {
|
|
5
|
+
outputPath: string;
|
|
6
|
+
token: string;
|
|
7
|
+
result: string;
|
|
8
|
+
message: string;
|
|
9
|
+
};
|
|
10
|
+
export type ReverseResult = {
|
|
11
|
+
filesWritten: number;
|
|
12
|
+
warnings: ReverseWarning[];
|
|
13
|
+
};
|
|
14
|
+
export declare function reverseDir(renderedDir: string, templateDir: string, options?: ReverseOptions): ReverseResult;
|
package/package.json
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gregorlohaus/tdir",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
7
|
"types": "./dist/index.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"tdir": "./dist/cli.js"
|
|
10
|
+
},
|
|
8
11
|
"exports": {
|
|
9
12
|
".": {
|
|
10
13
|
"import": "./dist/index.js",
|
|
@@ -13,7 +16,7 @@
|
|
|
13
16
|
},
|
|
14
17
|
"files": ["dist"],
|
|
15
18
|
"scripts": {
|
|
16
|
-
"build": "bun build ./index.ts --outdir ./dist --target node --external zod && bunx tsc --project tsconfig.build.json",
|
|
19
|
+
"build": "bun build ./index.ts ./cli.ts --outdir ./dist --target node --external zod && bunx tsc --project tsconfig.build.json",
|
|
17
20
|
"test": "bun test"
|
|
18
21
|
},
|
|
19
22
|
"devDependencies": {
|