@gregorlohaus/tdir 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.
- package/README.md +128 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +206 -0
- package/dist/parser.d.ts +5 -0
- package/dist/render.d.ts +3 -0
- package/package.json +25 -0
package/README.md
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# tdir
|
|
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.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
bun install tdir zod
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
Given a template directory:
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
templates/
|
|
17
|
+
<@if(context.web)>web/
|
|
18
|
+
index.html
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Where `index.html` contains:
|
|
22
|
+
|
|
23
|
+
```html
|
|
24
|
+
<html>
|
|
25
|
+
<@if(context.header.show)>
|
|
26
|
+
<head><@var(context.header.title)></head>
|
|
27
|
+
<@endif>
|
|
28
|
+
<body></body>
|
|
29
|
+
</html>
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Render it:
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import { initRenderer } from "tdir"
|
|
36
|
+
import { z } from "zod"
|
|
37
|
+
|
|
38
|
+
const createRenderer = initRenderer("./templates")
|
|
39
|
+
|
|
40
|
+
const render = createRenderer(z.object({
|
|
41
|
+
web: z.boolean(),
|
|
42
|
+
header: z.object({
|
|
43
|
+
show: z.boolean(),
|
|
44
|
+
title: z.string()
|
|
45
|
+
})
|
|
46
|
+
}))
|
|
47
|
+
|
|
48
|
+
render("./output", {
|
|
49
|
+
web: true,
|
|
50
|
+
header: { show: true, title: "Hello" }
|
|
51
|
+
})
|
|
52
|
+
// Creates: output/web/index.html with <head>Hello</head>
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Template directives
|
|
56
|
+
|
|
57
|
+
### In file contents
|
|
58
|
+
|
|
59
|
+
| Directive | Description |
|
|
60
|
+
|---|---|
|
|
61
|
+
| `<@if(context.x)>` | Conditional block (must end with `<@endif>`) |
|
|
62
|
+
| `<@elseif(context.y)>` | Else-if branch |
|
|
63
|
+
| `<@else>` | Else branch |
|
|
64
|
+
| `<@endif>` | End conditional block |
|
|
65
|
+
| `<@var(context.x)>` | Substitute with context value (default type: `string`) |
|
|
66
|
+
| `<@var(context.x:number)>` | Substitute with explicit type |
|
|
67
|
+
|
|
68
|
+
### In directory/file names
|
|
69
|
+
|
|
70
|
+
| Directive | Description |
|
|
71
|
+
|---|---|
|
|
72
|
+
| `<@if(context.x)>dirname` | Conditionally include directory/file |
|
|
73
|
+
| `<@var(context.x)>` | Dynamic directory/file name |
|
|
74
|
+
|
|
75
|
+
These can be combined: `<@if(context.web.create)><@var(context.web.dir)>` creates a directory named by `context.web.dir` only if `context.web.create` is true.
|
|
76
|
+
|
|
77
|
+
## Schema validation
|
|
78
|
+
|
|
79
|
+
`createRenderer` validates that your Zod schema matches the template variables. Mismatches throw `SchemaMismatchError`:
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
import { initRenderer, SchemaMismatchError } from "tdir"
|
|
83
|
+
import { z } from "zod"
|
|
84
|
+
|
|
85
|
+
const createRenderer = initRenderer("./templates")
|
|
86
|
+
|
|
87
|
+
// Template uses <@if(context.web)> which requires a boolean,
|
|
88
|
+
// but schema declares string -- throws SchemaMismatchError
|
|
89
|
+
createRenderer(z.object({
|
|
90
|
+
web: z.string(), // wrong type
|
|
91
|
+
header: z.object({ show: z.boolean(), title: z.string() })
|
|
92
|
+
}))
|
|
93
|
+
// SchemaMismatchError: Shema doesnt match used template variables: web: expected z.boolean() but schema has z.string()
|
|
94
|
+
|
|
95
|
+
// Schema is missing fields used in templates -- throws SchemaMismatchError
|
|
96
|
+
createRenderer(z.object({
|
|
97
|
+
web: z.boolean()
|
|
98
|
+
// missing header
|
|
99
|
+
}))
|
|
100
|
+
// SchemaMismatchError: Shema doesnt match used template variables: header: missing in schema
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Context validation
|
|
104
|
+
|
|
105
|
+
At render time, the context is validated by Zod. Invalid context throws `z.ZodError`:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
const render = createRenderer(z.object({
|
|
109
|
+
web: z.boolean(),
|
|
110
|
+
header: z.object({ show: z.boolean(), title: z.string() })
|
|
111
|
+
}))
|
|
112
|
+
|
|
113
|
+
render("./output", {})
|
|
114
|
+
// ZodError: required at "web", required at "header"
|
|
115
|
+
|
|
116
|
+
render("./output", { web: "not a boolean", header: { show: true, title: "Hi" } })
|
|
117
|
+
// ZodError: expected boolean, received string at "web"
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Unmatched directives
|
|
121
|
+
|
|
122
|
+
A `<@if>` without a matching `<@endif>` throws at render time:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
// If a template file contains <@if(context.x)> with no <@endif>
|
|
126
|
+
render("./output", { x: true })
|
|
127
|
+
// Error: Unmatched <@if> without <@endif>
|
|
128
|
+
```
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
interface Stringable {
|
|
3
|
+
toString: () => string;
|
|
4
|
+
}
|
|
5
|
+
interface Issue {
|
|
6
|
+
message: string;
|
|
7
|
+
path: Stringable;
|
|
8
|
+
}
|
|
9
|
+
export declare class SchemaMismatchError extends Error {
|
|
10
|
+
constructor(issue: Issue);
|
|
11
|
+
}
|
|
12
|
+
export declare const initRenderer: (dirPath: string) => <S extends z.ZodType>(userSchema: S) => (targetPath: string, context: z.infer<S>) => void;
|
|
13
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// index.ts
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
// parser.ts
|
|
5
|
+
import { readdirSync, statSync, readFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
var IF_RE = /<@if\(context\.(.+?)\)>/g;
|
|
8
|
+
var VAR_RE = /<@var\(context\.(.+?)(?::(\w+))?\)>/g;
|
|
9
|
+
function extractFromString(text, vars) {
|
|
10
|
+
for (const match of text.matchAll(IF_RE)) {
|
|
11
|
+
vars.push({ path: match[1], type: "boolean" });
|
|
12
|
+
}
|
|
13
|
+
for (const match of text.matchAll(VAR_RE)) {
|
|
14
|
+
vars.push({ path: match[1], type: match[2] ?? "string" });
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function walkDir(dirPath, vars) {
|
|
18
|
+
const entries = readdirSync(dirPath).sort();
|
|
19
|
+
for (const entry of entries) {
|
|
20
|
+
const fullPath = join(dirPath, entry);
|
|
21
|
+
extractFromString(entry, vars);
|
|
22
|
+
const stat = statSync(fullPath);
|
|
23
|
+
if (stat.isDirectory()) {
|
|
24
|
+
walkDir(fullPath, vars);
|
|
25
|
+
} else if (stat.isFile()) {
|
|
26
|
+
const content = readFileSync(fullPath, "utf-8");
|
|
27
|
+
extractFromString(content, vars);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function parse(dirPath) {
|
|
32
|
+
const vars = [];
|
|
33
|
+
walkDir(dirPath, vars);
|
|
34
|
+
const seen = new Set;
|
|
35
|
+
return vars.filter((v) => {
|
|
36
|
+
const key = `${v.path}:${v.type}`;
|
|
37
|
+
if (seen.has(key))
|
|
38
|
+
return false;
|
|
39
|
+
seen.add(key);
|
|
40
|
+
return true;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// render.ts
|
|
45
|
+
import { readdirSync as readdirSync2, statSync as statSync2, readFileSync as readFileSync2, mkdirSync, writeFileSync } from "node:fs";
|
|
46
|
+
import { join as join2 } from "node:path";
|
|
47
|
+
var IF_PATH_RE = /^<@if\(context\.(.+?)\)>(.*)$/;
|
|
48
|
+
var VAR_RE2 = /<@var\(context\.(.+?)(?::(\w+))?\)>/g;
|
|
49
|
+
var DIRECTIVE_RE = /<@(if|elseif|else|endif)(?:\(context\.(.+?)\))?>/g;
|
|
50
|
+
function resolve(context, path) {
|
|
51
|
+
const segments = path.split(".");
|
|
52
|
+
let current = context;
|
|
53
|
+
for (const seg of segments) {
|
|
54
|
+
if (current === null || current === undefined || typeof current !== "object")
|
|
55
|
+
return;
|
|
56
|
+
current = current[seg];
|
|
57
|
+
}
|
|
58
|
+
return current;
|
|
59
|
+
}
|
|
60
|
+
function processIfBlocks(content, context) {
|
|
61
|
+
let result = "";
|
|
62
|
+
let pos = 0;
|
|
63
|
+
const stack = [];
|
|
64
|
+
const re = new RegExp(DIRECTIVE_RE.source, "g");
|
|
65
|
+
let match;
|
|
66
|
+
function isEmitting() {
|
|
67
|
+
return stack.every((f) => f.active);
|
|
68
|
+
}
|
|
69
|
+
while ((match = re.exec(content)) !== null) {
|
|
70
|
+
const directive = match[1];
|
|
71
|
+
const condPath = match[2];
|
|
72
|
+
if (directive === "if") {
|
|
73
|
+
if (isEmitting())
|
|
74
|
+
result += content.slice(pos, match.index);
|
|
75
|
+
const truthy = !!resolve(context, condPath);
|
|
76
|
+
stack.push({ matched: truthy, active: truthy });
|
|
77
|
+
pos = re.lastIndex;
|
|
78
|
+
} else if (directive === "elseif") {
|
|
79
|
+
if (stack.length === 0)
|
|
80
|
+
throw new Error("Unexpected <@elseif> without <@if>");
|
|
81
|
+
const top = stack[stack.length - 1];
|
|
82
|
+
if (isEmitting())
|
|
83
|
+
result += content.slice(pos, match.index);
|
|
84
|
+
if (top.matched) {
|
|
85
|
+
top.active = false;
|
|
86
|
+
} else {
|
|
87
|
+
const truthy = !!resolve(context, condPath);
|
|
88
|
+
top.matched = truthy;
|
|
89
|
+
top.active = truthy;
|
|
90
|
+
}
|
|
91
|
+
pos = re.lastIndex;
|
|
92
|
+
} else if (directive === "else") {
|
|
93
|
+
if (stack.length === 0)
|
|
94
|
+
throw new Error("Unexpected <@else> without <@if>");
|
|
95
|
+
const top = stack[stack.length - 1];
|
|
96
|
+
if (isEmitting())
|
|
97
|
+
result += content.slice(pos, match.index);
|
|
98
|
+
top.active = !top.matched;
|
|
99
|
+
top.matched = true;
|
|
100
|
+
pos = re.lastIndex;
|
|
101
|
+
} else if (directive === "endif") {
|
|
102
|
+
if (stack.length === 0)
|
|
103
|
+
throw new Error("Unexpected <@endif> without <@if>");
|
|
104
|
+
if (isEmitting())
|
|
105
|
+
result += content.slice(pos, match.index);
|
|
106
|
+
stack.pop();
|
|
107
|
+
pos = re.lastIndex;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (stack.length > 0) {
|
|
111
|
+
throw new Error("Unmatched <@if> without <@endif>");
|
|
112
|
+
}
|
|
113
|
+
result += content.slice(pos);
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
116
|
+
function renderContent(content, context) {
|
|
117
|
+
const processed = processIfBlocks(content, context);
|
|
118
|
+
return processed.replace(VAR_RE2, (_match, path) => {
|
|
119
|
+
return String(resolve(context, path) ?? "");
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
function renderDir(srcDir, destDir, context) {
|
|
123
|
+
mkdirSync(destDir, { recursive: true });
|
|
124
|
+
const entries = readdirSync2(srcDir).sort();
|
|
125
|
+
for (const entry of entries) {
|
|
126
|
+
const srcPath = join2(srcDir, entry);
|
|
127
|
+
const stat = statSync2(srcPath);
|
|
128
|
+
const ifMatch = entry.match(IF_PATH_RE);
|
|
129
|
+
let outputName = entry;
|
|
130
|
+
if (ifMatch) {
|
|
131
|
+
const conditionPath = ifMatch[1];
|
|
132
|
+
if (!resolve(context, conditionPath))
|
|
133
|
+
continue;
|
|
134
|
+
outputName = ifMatch[2];
|
|
135
|
+
}
|
|
136
|
+
outputName = outputName.replace(VAR_RE2, (_match, path) => {
|
|
137
|
+
return String(resolve(context, path) ?? "");
|
|
138
|
+
});
|
|
139
|
+
const destPath = join2(destDir, outputName);
|
|
140
|
+
if (stat.isDirectory()) {
|
|
141
|
+
renderDir(srcPath, destPath, context);
|
|
142
|
+
} else {
|
|
143
|
+
mkdirSync(destDir, { recursive: true });
|
|
144
|
+
const content = readFileSync2(srcPath, "utf-8");
|
|
145
|
+
writeFileSync(destPath, renderContent(content, context));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// index.ts
|
|
151
|
+
class SchemaMismatchError extends Error {
|
|
152
|
+
constructor(issue) {
|
|
153
|
+
super(`Shema doesnt match used template variables: ${issue.path}: ${issue.message}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function validateSchemaMatchesTemplates(userSchema, variables) {
|
|
157
|
+
if (userSchema._zod.def.type !== "object") {
|
|
158
|
+
throw new SchemaMismatchError({ path: "", message: "Schema must be a z.object()" });
|
|
159
|
+
}
|
|
160
|
+
const expected = new Map;
|
|
161
|
+
for (const v of variables) {
|
|
162
|
+
const segments = v.path.split(".");
|
|
163
|
+
for (let i = 0;i < segments.length - 1; i++) {
|
|
164
|
+
const intermediate = segments.slice(0, i + 1).join(".");
|
|
165
|
+
if (!expected.has(intermediate)) {
|
|
166
|
+
expected.set(intermediate, "object");
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
expected.set(v.path, v.type);
|
|
170
|
+
}
|
|
171
|
+
for (const [path, expectedType] of expected) {
|
|
172
|
+
const segments = path.split(".");
|
|
173
|
+
let current = userSchema;
|
|
174
|
+
let currentPath = "";
|
|
175
|
+
for (const seg of segments) {
|
|
176
|
+
currentPath = currentPath ? `${currentPath}.${seg}` : seg;
|
|
177
|
+
if (current._zod.def.type !== "object") {
|
|
178
|
+
throw new SchemaMismatchError({ path: currentPath, message: `expected z.object() but schema has z.${current._zod.def.type}()` });
|
|
179
|
+
}
|
|
180
|
+
const shape = current.shape;
|
|
181
|
+
if (!(seg in shape)) {
|
|
182
|
+
throw new SchemaMismatchError({ path: currentPath, message: `missing in schema` });
|
|
183
|
+
}
|
|
184
|
+
current = shape[seg];
|
|
185
|
+
}
|
|
186
|
+
const actual = current._zod.def.type;
|
|
187
|
+
if (actual !== expectedType) {
|
|
188
|
+
throw new SchemaMismatchError({ path, message: `expected z.${expectedType}() but schema has z.${actual}()` });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
var initRenderer = (dirPath) => {
|
|
193
|
+
const variables = parse(dirPath);
|
|
194
|
+
const createRenderer = (userSchema) => {
|
|
195
|
+
validateSchemaMatchesTemplates(userSchema, variables);
|
|
196
|
+
return (targetPath, context) => {
|
|
197
|
+
userSchema.parse(context);
|
|
198
|
+
renderDir(dirPath, targetPath, context);
|
|
199
|
+
};
|
|
200
|
+
};
|
|
201
|
+
return createRenderer;
|
|
202
|
+
};
|
|
203
|
+
export {
|
|
204
|
+
initRenderer,
|
|
205
|
+
SchemaMismatchError
|
|
206
|
+
};
|
package/dist/parser.d.ts
ADDED
package/dist/render.d.ts
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gregorlohaus/tdir",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"import": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"files": ["dist"],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "bun build ./index.ts --outdir ./dist --target node --external zod && bunx tsc --project tsconfig.build.json",
|
|
16
|
+
"test": "bun test"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/bun": "^1.3.11",
|
|
20
|
+
"typescript": "^5"
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"zod": "^4"
|
|
24
|
+
}
|
|
25
|
+
}
|