init
This commit is contained in:
12
.envrc
Normal file
12
.envrc
Normal file
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
export DIRENV_WARN_TIMEOUT=20s
|
||||
|
||||
eval "$(devenv direnvrc)"
|
||||
|
||||
# `use devenv` supports the same options as the `devenv shell` command.
|
||||
#
|
||||
# To silence all output, use `--quiet`.
|
||||
#
|
||||
# Example usage: use devenv --quiet --impure --option services.postgres.enable:bool true
|
||||
use devenv
|
||||
45
.gitignore
vendored
Normal file
45
.gitignore
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
# dependencies (bun install)
|
||||
node_modules
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
|
||||
# Devenv
|
||||
.devenv*
|
||||
devenv.local.nix
|
||||
devenv.local.yaml
|
||||
|
||||
# direnv
|
||||
.direnv
|
||||
|
||||
# pre-commit
|
||||
.pre-commit-config.yaml
|
||||
4
.helix/languages.toml
Normal file
4
.helix/languages.toml
Normal file
@@ -0,0 +1,4 @@
|
||||
[[language]]
|
||||
name="typescript"
|
||||
roots = ["package.json"]
|
||||
language-servers = ["typescript-language-server"]
|
||||
106
CLAUDE.md
Normal file
106
CLAUDE.md
Normal file
@@ -0,0 +1,106 @@
|
||||
|
||||
Default to using Bun instead of Node.js.
|
||||
|
||||
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
|
||||
- Use `bun test` instead of `jest` or `vitest`
|
||||
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
|
||||
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
|
||||
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
|
||||
- Use `bunx <package> <command>` instead of `npx <package> <command>`
|
||||
- Bun automatically loads .env, so don't use dotenv.
|
||||
|
||||
## APIs
|
||||
|
||||
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
|
||||
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
|
||||
- `Bun.redis` for Redis. Don't use `ioredis`.
|
||||
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
|
||||
- `WebSocket` is built-in. Don't use `ws`.
|
||||
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
|
||||
- Bun.$`ls` instead of execa.
|
||||
|
||||
## Testing
|
||||
|
||||
Use `bun test` to run tests.
|
||||
|
||||
```ts#index.test.ts
|
||||
import { test, expect } from "bun:test";
|
||||
|
||||
test("hello world", () => {
|
||||
expect(1).toBe(1);
|
||||
});
|
||||
```
|
||||
|
||||
## Frontend
|
||||
|
||||
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
|
||||
|
||||
Server:
|
||||
|
||||
```ts#index.ts
|
||||
import index from "./index.html"
|
||||
|
||||
Bun.serve({
|
||||
routes: {
|
||||
"/": index,
|
||||
"/api/users/:id": {
|
||||
GET: (req) => {
|
||||
return new Response(JSON.stringify({ id: req.params.id }));
|
||||
},
|
||||
},
|
||||
},
|
||||
// optional websocket support
|
||||
websocket: {
|
||||
open: (ws) => {
|
||||
ws.send("Hello, world!");
|
||||
},
|
||||
message: (ws, message) => {
|
||||
ws.send(message);
|
||||
},
|
||||
close: (ws) => {
|
||||
// handle close
|
||||
}
|
||||
},
|
||||
development: {
|
||||
hmr: true,
|
||||
console: true,
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
|
||||
|
||||
```html#index.html
|
||||
<html>
|
||||
<body>
|
||||
<h1>Hello, world!</h1>
|
||||
<script type="module" src="./frontend.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
With the following `frontend.tsx`:
|
||||
|
||||
```tsx#frontend.tsx
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
// import .css files directly and it works
|
||||
import './index.css';
|
||||
|
||||
const root = createRoot(document.body);
|
||||
|
||||
export default function Frontend() {
|
||||
return <h1>Hello, world!</h1>;
|
||||
}
|
||||
|
||||
root.render(<Frontend />);
|
||||
```
|
||||
|
||||
Then, run index.ts
|
||||
|
||||
```sh
|
||||
bun --hot ./index.ts
|
||||
```
|
||||
|
||||
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.
|
||||
128
README.md
Normal file
128
README.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# tdir
|
||||
|
||||
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.
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
bun install tdir zod
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
Given a template directory:
|
||||
|
||||
```
|
||||
templates/
|
||||
<@if(context.web)>web/
|
||||
index.html
|
||||
```
|
||||
|
||||
Where `index.html` contains:
|
||||
|
||||
```html
|
||||
<html>
|
||||
<@if(context.header.show)>
|
||||
<head><@var(context.header.title)></head>
|
||||
<@endif>
|
||||
<body></body>
|
||||
</html>
|
||||
```
|
||||
|
||||
Render it:
|
||||
|
||||
```ts
|
||||
import { initRenderer } from "tdir"
|
||||
import { z } from "zod"
|
||||
|
||||
const createRenderer = initRenderer("./templates")
|
||||
|
||||
const render = createRenderer(z.object({
|
||||
web: z.boolean(),
|
||||
header: z.object({
|
||||
show: z.boolean(),
|
||||
title: z.string()
|
||||
})
|
||||
}))
|
||||
|
||||
render("./output", {
|
||||
web: true,
|
||||
header: { show: true, title: "Hello" }
|
||||
})
|
||||
// Creates: output/web/index.html with <head>Hello</head>
|
||||
```
|
||||
|
||||
## Template directives
|
||||
|
||||
### In file contents
|
||||
|
||||
| Directive | Description |
|
||||
|---|---|
|
||||
| `<@if(context.x)>` | Conditional block (must end with `<@endif>`) |
|
||||
| `<@elseif(context.y)>` | Else-if branch |
|
||||
| `<@else>` | Else branch |
|
||||
| `<@endif>` | End conditional block |
|
||||
| `<@var(context.x)>` | Substitute with context value (default type: `string`) |
|
||||
| `<@var(context.x:number)>` | Substitute with explicit type |
|
||||
|
||||
### In directory/file names
|
||||
|
||||
| Directive | Description |
|
||||
|---|---|
|
||||
| `<@if(context.x)>dirname` | Conditionally include directory/file |
|
||||
| `<@var(context.x)>` | Dynamic directory/file name |
|
||||
|
||||
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.
|
||||
|
||||
## Schema validation
|
||||
|
||||
`createRenderer` validates that your Zod schema matches the template variables. Mismatches throw `SchemaMismatchError`:
|
||||
|
||||
```ts
|
||||
import { initRenderer, SchemaMismatchError } from "tdir"
|
||||
import { z } from "zod"
|
||||
|
||||
const createRenderer = initRenderer("./templates")
|
||||
|
||||
// Template uses <@if(context.web)> which requires a boolean,
|
||||
// but schema declares string -- throws SchemaMismatchError
|
||||
createRenderer(z.object({
|
||||
web: z.string(), // wrong type
|
||||
header: z.object({ show: z.boolean(), title: z.string() })
|
||||
}))
|
||||
// SchemaMismatchError: Shema doesnt match used template variables: web: expected z.boolean() but schema has z.string()
|
||||
|
||||
// Schema is missing fields used in templates -- throws SchemaMismatchError
|
||||
createRenderer(z.object({
|
||||
web: z.boolean()
|
||||
// missing header
|
||||
}))
|
||||
// SchemaMismatchError: Shema doesnt match used template variables: header: missing in schema
|
||||
```
|
||||
|
||||
## Context validation
|
||||
|
||||
At render time, the context is validated by Zod. Invalid context throws `z.ZodError`:
|
||||
|
||||
```ts
|
||||
const render = createRenderer(z.object({
|
||||
web: z.boolean(),
|
||||
header: z.object({ show: z.boolean(), title: z.string() })
|
||||
}))
|
||||
|
||||
render("./output", {})
|
||||
// ZodError: required at "web", required at "header"
|
||||
|
||||
render("./output", { web: "not a boolean", header: { show: true, title: "Hi" } })
|
||||
// ZodError: expected boolean, received string at "web"
|
||||
```
|
||||
|
||||
## Unmatched directives
|
||||
|
||||
A `<@if>` without a matching `<@endif>` throws at render time:
|
||||
|
||||
```ts
|
||||
// If a template file contains <@if(context.x)> with no <@endif>
|
||||
render("./output", { x: true })
|
||||
// Error: Unmatched <@if> without <@endif>
|
||||
```
|
||||
31
bun.lock
Normal file
31
bun.lock
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "tdir",
|
||||
"dependencies": {
|
||||
"zod": "^4.3.6",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.11",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
|
||||
|
||||
"@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
}
|
||||
}
|
||||
123
devenv.lock
Normal file
123
devenv.lock
Normal file
@@ -0,0 +1,123 @@
|
||||
{
|
||||
"nodes": {
|
||||
"devenv": {
|
||||
"locked": {
|
||||
"dir": "src/modules",
|
||||
"lastModified": 1775585812,
|
||||
"owner": "cachix",
|
||||
"repo": "devenv",
|
||||
"rev": "35b8c42eb10c196dc84611852325c722b6f10750",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"dir": "src/modules",
|
||||
"owner": "cachix",
|
||||
"repo": "devenv",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-compat": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1767039857,
|
||||
"owner": "NixOS",
|
||||
"repo": "flake-compat",
|
||||
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"repo": "flake-compat",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"git-hooks": {
|
||||
"inputs": {
|
||||
"flake-compat": "flake-compat",
|
||||
"gitignore": "gitignore",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1775585728,
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"rev": "580633fa3fe5fc0379905986543fd7495481913d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"gitignore": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"git-hooks",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1762808025,
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"rev": "cb5e3fdca1de58ccbc3ef53de65bd372b48f567c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"inputs": {
|
||||
"nixpkgs-src": "nixpkgs-src"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1774287239,
|
||||
"owner": "cachix",
|
||||
"repo": "devenv-nixpkgs",
|
||||
"rev": "fa7125ea7f1ae5430010a6e071f68375a39bd24c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"ref": "rolling",
|
||||
"repo": "devenv-nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-src": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1769922788,
|
||||
"narHash": "sha256-H3AfG4ObMDTkTJYkd8cz1/RbY9LatN5Mk4UF48VuSXc=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "207d15f1a6603226e1e223dc79ac29c7846da32e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"devenv": "devenv",
|
||||
"git-hooks": "git-hooks",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"pre-commit-hooks": [
|
||||
"git-hooks"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
7
devenv.nix
Normal file
7
devenv.nix
Normal file
@@ -0,0 +1,7 @@
|
||||
{ pkgs, lib, config, inputs, ... }:
|
||||
|
||||
{
|
||||
packages = [ pkgs.bun ];
|
||||
languages.typescript.enable = true;
|
||||
|
||||
}
|
||||
15
devenv.yaml
Normal file
15
devenv.yaml
Normal file
@@ -0,0 +1,15 @@
|
||||
# yaml-language-server: $schema=https://devenv.sh/devenv.schema.json
|
||||
inputs:
|
||||
nixpkgs:
|
||||
url: github:cachix/devenv-nixpkgs/rolling
|
||||
|
||||
# If you're using non-OSS software, you can set allowUnfree to true.
|
||||
# allowUnfree: true
|
||||
|
||||
# If you're willing to use a package that's vulnerable
|
||||
# permittedInsecurePackages:
|
||||
# - "openssl-1.1.1w"
|
||||
|
||||
# If you have more than one devenv you can merge them
|
||||
#imports:
|
||||
# - ./backend
|
||||
291
index.test.ts
Normal file
291
index.test.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import { expect, test, beforeEach, afterEach } from 'bun:test'
|
||||
import { initRenderer, SchemaMismatchError } from ".";
|
||||
import { z } from "zod"
|
||||
import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { tmpdir } from "node:os"
|
||||
|
||||
const ifExampleSchema = z.object({
|
||||
web: z.boolean(),
|
||||
header: z.object({
|
||||
render: z.boolean(),
|
||||
text: z.string()
|
||||
})
|
||||
})
|
||||
|
||||
let tmp: string
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = mkdtempSync(join(tmpdir(), "tdir-test-"))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmp, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test("createRenderer validates schema matches templates", () => {
|
||||
const createRenderer = initRenderer("./testdata/if_example")
|
||||
expect(() => createRenderer(ifExampleSchema)).not.toThrow()
|
||||
expect(() => createRenderer(z.object({ web: z.boolean() }))).toThrow(SchemaMismatchError)
|
||||
expect(() => createRenderer(z.object({
|
||||
web: z.string(),
|
||||
header: z.object({ render: z.boolean(), text: z.string() })
|
||||
}))).toThrow(SchemaMismatchError)
|
||||
})
|
||||
|
||||
test("render validates context via zod", () => {
|
||||
const createRenderer = initRenderer("./testdata/if_example")
|
||||
const render = createRenderer(ifExampleSchema)
|
||||
expect(() => render(tmp, {} as any)).toThrow(z.ZodError)
|
||||
expect(() => render(tmp, { web: "nope" } as any)).toThrow(z.ZodError)
|
||||
expect(() => render(tmp, { web: true, header: { render: true } } as any)).toThrow(z.ZodError)
|
||||
})
|
||||
|
||||
test("renders with web=true, header rendered", () => {
|
||||
const createRenderer = initRenderer("./testdata/if_example")
|
||||
const render = createRenderer(ifExampleSchema)
|
||||
render(tmp, { web: true, header: { render: true, text: "My Title" } })
|
||||
|
||||
const outFile = join(tmp, "web", "if_example.html")
|
||||
expect(existsSync(outFile)).toBe(true)
|
||||
|
||||
const content = readFileSync(outFile, "utf-8")
|
||||
expect(content).toContain("My Title")
|
||||
expect(content).toContain("<head>")
|
||||
expect(content).toContain("<body>")
|
||||
})
|
||||
|
||||
test("wrong schema throws error",() => {
|
||||
const createRenderer = initRenderer("./testdata/if_example")
|
||||
expect(() => createRenderer(z.object({
|
||||
web: z.string(),
|
||||
header: z.object({
|
||||
render: z.string(),
|
||||
text: z.boolean()
|
||||
})
|
||||
}))).toThrow(new SchemaMismatchError({message: 'expected z.boolean() but schema has z.string()', path: 'web'}))
|
||||
})
|
||||
|
||||
test("wrong schema throws error",() => {
|
||||
const createRenderer = initRenderer("./testdata/if_example")
|
||||
expect(() => createRenderer(z.object({
|
||||
web: z.boolean(),
|
||||
header: z.object({
|
||||
render: z.string(),
|
||||
text: z.boolean()
|
||||
})
|
||||
}))).toThrow(new SchemaMismatchError({message: 'expected z.boolean() but schema has z.string()', path: 'header.render'}))
|
||||
})
|
||||
|
||||
test("wrong schema throws error",() => {
|
||||
const createRenderer = initRenderer("./testdata/if_example")
|
||||
expect(() => createRenderer(z.object({
|
||||
web: z.boolean(),
|
||||
header: z.object({
|
||||
render: z.boolean(),
|
||||
text: z.boolean()
|
||||
})
|
||||
}))).toThrow(new SchemaMismatchError({message: 'expected z.string() but schema has z.boolean()', path: 'header.text'}))
|
||||
})
|
||||
|
||||
test("renders with web=true, header not rendered", () => {
|
||||
const createRenderer = initRenderer("./testdata/if_example")
|
||||
const render = createRenderer(ifExampleSchema)
|
||||
render(tmp, { web: true, header: { render: false, text: "Ignored" } })
|
||||
|
||||
const outFile = join(tmp, "web", "if_example.html")
|
||||
expect(existsSync(outFile)).toBe(true)
|
||||
|
||||
const content = readFileSync(outFile, "utf-8")
|
||||
expect(content).not.toContain("Ignored")
|
||||
expect(content).not.toContain("<head>")
|
||||
expect(content).toContain("<body>")
|
||||
})
|
||||
|
||||
test("renders with web=false, skips directory", () => {
|
||||
const createRenderer = initRenderer("./testdata/if_example")
|
||||
const render = createRenderer(ifExampleSchema)
|
||||
render(tmp, { web: false, header: { render: true, text: "Hello" } })
|
||||
|
||||
expect(existsSync(join(tmp, "web"))).toBe(false)
|
||||
})
|
||||
|
||||
test("vars in path are added to schema",() => {
|
||||
const createRenderer = initRenderer("./testdata/var_in_path")
|
||||
expect(() => createRenderer(z.object({
|
||||
web: z.boolean(),
|
||||
header: z.object({
|
||||
render: z.boolean(),
|
||||
text: z.boolean()
|
||||
})
|
||||
}))).toThrow(new SchemaMismatchError({message: 'expected z.object() but schema has z.boolean()', path: 'web'}))
|
||||
})
|
||||
|
||||
test("vars in path renders correctrly",() => {
|
||||
const createRenderer = initRenderer("./testdata/var_in_path")
|
||||
const render = createRenderer(z.object({
|
||||
web: z.object({
|
||||
create: z.boolean(),
|
||||
dir: z.string()
|
||||
}),
|
||||
header: z.object({
|
||||
render: z.boolean(),
|
||||
text: z.string()
|
||||
})
|
||||
}))
|
||||
render(tmp,{
|
||||
web: {
|
||||
create: true,
|
||||
dir: "web"
|
||||
},
|
||||
header: {
|
||||
render: false,
|
||||
text: "test"
|
||||
}
|
||||
})
|
||||
expect(existsSync(join(tmp, "web"))).toBe(true)
|
||||
const outFile = join(tmp, "web", "var_in_path_example.html")
|
||||
expect(existsSync(outFile)).toBe(true)
|
||||
|
||||
const content = readFileSync(outFile, "utf-8")
|
||||
expect(content).not.toContain("Ignored")
|
||||
expect(content).not.toContain("<head>")
|
||||
expect(content).toContain("<body>")
|
||||
})
|
||||
|
||||
test("vars in path renders correctrly",() => {
|
||||
const createRenderer = initRenderer("./testdata/var_in_path")
|
||||
const render = createRenderer(z.object({
|
||||
web: z.object({
|
||||
create: z.boolean(),
|
||||
dir: z.string()
|
||||
}),
|
||||
header: z.object({
|
||||
render: z.boolean(),
|
||||
text: z.string()
|
||||
})
|
||||
}))
|
||||
render(tmp,{
|
||||
web: {
|
||||
create: true,
|
||||
dir: "test"
|
||||
},
|
||||
header: {
|
||||
render: true,
|
||||
text: "test"
|
||||
}
|
||||
})
|
||||
expect(existsSync(join(tmp, "test"))).toBe(true)
|
||||
const outFile = join(tmp, "test", "var_in_path_example.html")
|
||||
expect(existsSync(outFile)).toBe(true)
|
||||
|
||||
const content = readFileSync(outFile, "utf-8")
|
||||
expect(content).toContain("test")
|
||||
expect(content).toContain("<head>")
|
||||
expect(content).toContain("<body>")
|
||||
})
|
||||
|
||||
test("nested if dir renders correctly",() => {
|
||||
const createRenderer = initRenderer("./testdata/nested_if_dir")
|
||||
const render = createRenderer(z.object({
|
||||
web: z.boolean(),
|
||||
webnested: z.boolean()
|
||||
}))
|
||||
render(tmp,{
|
||||
web: true,
|
||||
webnested: true
|
||||
})
|
||||
expect(existsSync(join(tmp, "web"))).toBe(true)
|
||||
})
|
||||
|
||||
test("nested if dir renders correctly",() => {
|
||||
const createRenderer = initRenderer("./testdata/nested_if_dir_named")
|
||||
const render = createRenderer(z.object({
|
||||
web: z.boolean(),
|
||||
webnested: z.boolean()
|
||||
}))
|
||||
render(tmp,{
|
||||
web: true,
|
||||
webnested: true
|
||||
})
|
||||
expect(existsSync(join(tmp, "nested" , "web"))).toBe(true)
|
||||
})
|
||||
|
||||
test("multiple dirs 1 var",() => {
|
||||
const createRenderer = initRenderer("./testdata/multi_dir_one_var")
|
||||
const render = createRenderer(z.object({
|
||||
web: z.boolean(),
|
||||
}))
|
||||
render(tmp,{
|
||||
web: true,
|
||||
})
|
||||
expect(existsSync(join(tmp, "dir1"))).toBe(true)
|
||||
expect(existsSync(join(tmp, "dir2"))).toBe(true)
|
||||
expect(existsSync(join(tmp, "dir1", "dir1n"))).toBe(true)
|
||||
})
|
||||
|
||||
test("file if",() => {
|
||||
const createRenderer = initRenderer("./testdata/file_if")
|
||||
const render = createRenderer(z.object({
|
||||
web: z.boolean(),
|
||||
file: z.boolean(),
|
||||
text: z.string()
|
||||
}))
|
||||
render(tmp,{
|
||||
web: true,
|
||||
file: true,
|
||||
text: "test"
|
||||
})
|
||||
expect(existsSync(join(tmp, "web"))).toBe(true)
|
||||
const outFile = join(tmp, "web", "example.txt")
|
||||
expect(existsSync(outFile)).toBe(true)
|
||||
|
||||
const content = readFileSync(outFile, "utf-8")
|
||||
expect(content).toContain("test")
|
||||
})
|
||||
|
||||
test("no endif should throw",() => {
|
||||
const createRenderer = initRenderer("./testdata/no_end_if")
|
||||
const render = createRenderer(z.object({
|
||||
test: z.boolean(),
|
||||
}))
|
||||
expect( () => render(tmp,{
|
||||
test: true,
|
||||
})).toThrow()
|
||||
})
|
||||
|
||||
test("if elseif else",() => {
|
||||
const createRenderer = initRenderer("./testdata/if_elseif_else_in_file")
|
||||
const render = createRenderer(z.object({
|
||||
test: z.boolean(),
|
||||
test2: z.boolean()
|
||||
}))
|
||||
render(tmp,{
|
||||
test: true,
|
||||
test2: true
|
||||
})
|
||||
let outFile = join(tmp, "test.txt")
|
||||
expect(existsSync(outFile)).toBe(true)
|
||||
|
||||
let content = readFileSync(outFile, "utf-8")
|
||||
expect(content).toContain("test")
|
||||
render(tmp,{
|
||||
test: false,
|
||||
test2: true
|
||||
})
|
||||
outFile = join(tmp, "test.txt")
|
||||
expect(existsSync(outFile)).toBe(true)
|
||||
|
||||
content = readFileSync(outFile, "utf-8")
|
||||
expect(content).toContain("test2")
|
||||
render(tmp,{
|
||||
test: false,
|
||||
test2: false
|
||||
})
|
||||
outFile = join(tmp, "test.txt")
|
||||
expect(existsSync(outFile)).toBe(true)
|
||||
|
||||
content = readFileSync(outFile, "utf-8")
|
||||
expect(content).toContain("test3")
|
||||
})
|
||||
|
||||
120
index.ts
Normal file
120
index.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { z } from "zod"
|
||||
import { parse, type TemplateVariable } from "./parser"
|
||||
import { renderDir } from "./render"
|
||||
|
||||
const zodTypeMap: Record<string, () => z.ZodType> = {
|
||||
string: () => z.string(),
|
||||
number: () => z.number(),
|
||||
boolean: () => z.boolean(),
|
||||
}
|
||||
|
||||
type ShapeNode = {
|
||||
type?: string
|
||||
children: Map<string, ShapeNode>
|
||||
}
|
||||
|
||||
function buildTree(variables: TemplateVariable[]): ShapeNode {
|
||||
const root: ShapeNode = { children: new Map() }
|
||||
for (const v of variables) {
|
||||
const segments = v.path.split(".")
|
||||
let node = root
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const seg = segments[i]!
|
||||
if (!node.children.has(seg)) {
|
||||
node.children.set(seg, { children: new Map() })
|
||||
}
|
||||
node = node.children.get(seg)!
|
||||
if (i === segments.length - 1) {
|
||||
node.type = v.type
|
||||
}
|
||||
}
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
function nodeToSchema(node: ShapeNode): z.ZodType {
|
||||
if (node.children.size === 0 && node.type) {
|
||||
const factory = zodTypeMap[node.type]
|
||||
if (!factory) throw new Error(`Unsupported type: ${node.type}`)
|
||||
return factory()
|
||||
}
|
||||
const shape: Record<string, z.ZodType> = {}
|
||||
for (const [key, child] of node.children) {
|
||||
shape[key] = nodeToSchema(child)
|
||||
}
|
||||
return z.object(shape)
|
||||
}
|
||||
|
||||
function inferSchema(variables: TemplateVariable[]): z.ZodType {
|
||||
return nodeToSchema(buildTree(variables))
|
||||
}
|
||||
|
||||
interface Stringable {
|
||||
toString: () => string
|
||||
}
|
||||
|
||||
interface Issue {
|
||||
message: string,
|
||||
path: Stringable
|
||||
}
|
||||
|
||||
export class SchemaMismatchError extends Error {
|
||||
constructor(issue: Issue) {
|
||||
super(`Shema doesnt match used template variables: ${issue.path}: ${issue.message}` )
|
||||
}
|
||||
}
|
||||
|
||||
function validateSchemaMatchesTemplates(userSchema: z.ZodType, variables: TemplateVariable[]) {
|
||||
if (userSchema._zod.def.type !== "object") {
|
||||
throw new SchemaMismatchError({ path: "", message: "Schema must be a z.object()" })
|
||||
}
|
||||
|
||||
// Collect all expected paths: leaf variables + intermediate segments must be objects
|
||||
const expected = new Map<string, string>()
|
||||
for (const v of variables) {
|
||||
const segments = v.path.split(".")
|
||||
for (let i = 0; i < segments.length - 1; i++) {
|
||||
const intermediate = segments.slice(0, i + 1).join(".")
|
||||
if (!expected.has(intermediate)) {
|
||||
expected.set(intermediate, "object")
|
||||
}
|
||||
}
|
||||
expected.set(v.path, v.type)
|
||||
}
|
||||
|
||||
for (const [path, expectedType] of expected) {
|
||||
const segments = path.split(".")
|
||||
let current = userSchema
|
||||
let currentPath = ""
|
||||
for (const seg of segments) {
|
||||
currentPath = currentPath ? `${currentPath}.${seg}` : seg
|
||||
if (current._zod.def.type !== "object") {
|
||||
throw new SchemaMismatchError({ path: currentPath, message: `expected z.object() but schema has z.${current._zod.def.type as string}()` })
|
||||
}
|
||||
const shape = (current as z.ZodObject<any>).shape
|
||||
if (!(seg in shape)) {
|
||||
throw new SchemaMismatchError({ path: currentPath, message: `missing in schema` })
|
||||
}
|
||||
current = shape[seg]
|
||||
}
|
||||
const actual = current._zod.def.type as string
|
||||
if (actual !== expectedType) {
|
||||
throw new SchemaMismatchError({ path, message: `expected z.${expectedType}() but schema has z.${actual}()` })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const initRenderer = (dirPath: string) => {
|
||||
const variables = parse(dirPath)
|
||||
|
||||
const createRenderer = <S extends z.ZodType>(userSchema: S) => {
|
||||
validateSchemaMatchesTemplates(userSchema, variables)
|
||||
return (targetPath: string, context: z.infer<S>) => {
|
||||
userSchema.parse(context)
|
||||
renderDir(dirPath, targetPath, context as Record<string, unknown>)
|
||||
}
|
||||
}
|
||||
|
||||
return createRenderer
|
||||
}
|
||||
|
||||
14
package.json
Normal file
14
package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "tdir",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.11"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
}
|
||||
47
parser.ts
Normal file
47
parser.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { readdirSync, statSync, readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
|
||||
export type TemplateVariable = {
|
||||
path: string
|
||||
type: string
|
||||
}
|
||||
|
||||
const IF_RE = /<@if\(context\.(.+?)\)>/g
|
||||
const VAR_RE = /<@var\(context\.(.+?)(?::(\w+))?\)>/g
|
||||
|
||||
function extractFromString(text: string, vars: TemplateVariable[]) {
|
||||
for (const match of text.matchAll(IF_RE)) {
|
||||
vars.push({ path: match[1]!, type: "boolean" })
|
||||
}
|
||||
for (const match of text.matchAll(VAR_RE)) {
|
||||
vars.push({ path: match[1]!, type: match[2] ?? "string" })
|
||||
}
|
||||
}
|
||||
|
||||
function walkDir(dirPath: string, vars: TemplateVariable[]) {
|
||||
const entries = readdirSync(dirPath).sort()
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dirPath, entry)
|
||||
extractFromString(entry, vars)
|
||||
|
||||
const stat = statSync(fullPath)
|
||||
if (stat.isDirectory()) {
|
||||
walkDir(fullPath, vars)
|
||||
} else if (stat.isFile()) {
|
||||
const content = readFileSync(fullPath, "utf-8")
|
||||
extractFromString(content, vars)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function parse(dirPath: string): TemplateVariable[] {
|
||||
const vars: TemplateVariable[] = []
|
||||
walkDir(dirPath, vars)
|
||||
const seen = new Set<string>()
|
||||
return vars.filter(v => {
|
||||
const key = `${v.path}:${v.type}`
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
123
render.ts
Normal file
123
render.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { readdirSync, statSync, readFileSync, mkdirSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
|
||||
const IF_PATH_RE = /^<@if\(context\.(.+?)\)>(.*)$/
|
||||
const VAR_RE = /<@var\(context\.(.+?)(?::(\w+))?\)>/g
|
||||
const DIRECTIVE_RE = /<@(if|elseif|else|endif)(?:\(context\.(.+?)\))?>/g
|
||||
|
||||
function resolve(context: Record<string, unknown>, path: string): unknown {
|
||||
const segments = path.split(".")
|
||||
let current: unknown = context
|
||||
for (const seg of segments) {
|
||||
if (current === null || current === undefined || typeof current !== "object") return undefined
|
||||
current = (current as Record<string, unknown>)[seg]
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
// Each stack frame tracks: did any branch match yet, is the current branch active
|
||||
type IfFrame = { matched: boolean; active: boolean }
|
||||
|
||||
function processIfBlocks(content: string, context: Record<string, unknown>): string {
|
||||
let result = ""
|
||||
let pos = 0
|
||||
const stack: IfFrame[] = []
|
||||
|
||||
const re = new RegExp(DIRECTIVE_RE.source, "g")
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
function isEmitting(): boolean {
|
||||
return stack.every(f => f.active)
|
||||
}
|
||||
|
||||
while ((match = re.exec(content)) !== null) {
|
||||
const directive = match[1]!
|
||||
const condPath = match[2]
|
||||
|
||||
if (directive === "if") {
|
||||
if (isEmitting()) result += content.slice(pos, match.index)
|
||||
const truthy = !!resolve(context, condPath!)
|
||||
stack.push({ matched: truthy, active: truthy })
|
||||
pos = re.lastIndex
|
||||
} else if (directive === "elseif") {
|
||||
if (stack.length === 0) throw new Error("Unexpected <@elseif> without <@if>")
|
||||
const top = stack[stack.length - 1]!
|
||||
// Emit text before this directive if current branch was active
|
||||
if (isEmitting()) result += content.slice(pos, match.index)
|
||||
if (top.matched) {
|
||||
// A previous branch already matched — skip this one
|
||||
top.active = false
|
||||
} else {
|
||||
const truthy = !!resolve(context, condPath!)
|
||||
top.matched = truthy
|
||||
top.active = truthy
|
||||
}
|
||||
pos = re.lastIndex
|
||||
} else if (directive === "else") {
|
||||
if (stack.length === 0) throw new Error("Unexpected <@else> without <@if>")
|
||||
const top = stack[stack.length - 1]!
|
||||
if (isEmitting()) result += content.slice(pos, match.index)
|
||||
top.active = !top.matched
|
||||
top.matched = true
|
||||
pos = re.lastIndex
|
||||
} else if (directive === "endif") {
|
||||
if (stack.length === 0) throw new Error("Unexpected <@endif> without <@if>")
|
||||
if (isEmitting()) result += content.slice(pos, match.index)
|
||||
stack.pop()
|
||||
pos = re.lastIndex
|
||||
}
|
||||
}
|
||||
|
||||
if (stack.length > 0) {
|
||||
throw new Error("Unmatched <@if> without <@endif>")
|
||||
}
|
||||
|
||||
result += content.slice(pos)
|
||||
return result
|
||||
}
|
||||
|
||||
function renderContent(content: string, context: Record<string, unknown>): string {
|
||||
const processed = processIfBlocks(content, context)
|
||||
return processed.replace(VAR_RE, (_match, path: string) => {
|
||||
return String(resolve(context, path) ?? "")
|
||||
})
|
||||
}
|
||||
|
||||
function renderDir(
|
||||
srcDir: string,
|
||||
destDir: string,
|
||||
context: Record<string, unknown>,
|
||||
) {
|
||||
mkdirSync(destDir, { recursive: true })
|
||||
const entries = readdirSync(srcDir).sort()
|
||||
|
||||
for (const entry of entries) {
|
||||
const srcPath = join(srcDir, entry)
|
||||
const stat = statSync(srcPath)
|
||||
|
||||
// Check if path segment has an @if directive
|
||||
const ifMatch = entry.match(IF_PATH_RE)
|
||||
let outputName = entry
|
||||
if (ifMatch) {
|
||||
const conditionPath = ifMatch[1]!
|
||||
if (!resolve(context, conditionPath)) continue
|
||||
outputName = ifMatch[2]!
|
||||
}
|
||||
|
||||
// Process @var in the output name
|
||||
outputName = outputName.replace(VAR_RE, (_match, path: string) => {
|
||||
return String(resolve(context, path) ?? "")
|
||||
})
|
||||
|
||||
const destPath = join(destDir, outputName)
|
||||
if (stat.isDirectory()) {
|
||||
renderDir(srcPath, destPath, context)
|
||||
} else {
|
||||
mkdirSync(destDir, { recursive: true })
|
||||
const content = readFileSync(srcPath, "utf-8")
|
||||
writeFileSync(destPath, renderContent(content, context))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { renderDir, renderContent }
|
||||
1
testdata/file_if/<@if(context.web)>web/<@if(context.file)>example.txt
vendored
Normal file
1
testdata/file_if/<@if(context.web)>web/<@if(context.file)>example.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<@var(context.text)>
|
||||
7
testdata/if_elseif_else_in_file/test.txt
vendored
Normal file
7
testdata/if_elseif_else_in_file/test.txt
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
<@if(context.test)>
|
||||
test
|
||||
<@elseif(context.test2)>
|
||||
test2
|
||||
<@else>
|
||||
test3
|
||||
<@endif>
|
||||
7
testdata/if_example/<@if(context.web)>web/if_example.html
vendored
Normal file
7
testdata/if_example/<@if(context.web)>web/if_example.html
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
<document>
|
||||
<@if(context.header.render)>
|
||||
<head> <@var(context.header.text)> </head>
|
||||
<@endif>
|
||||
<body>
|
||||
</body>
|
||||
</document>
|
||||
2
testdata/no_end_if/test.txt
vendored
Normal file
2
testdata/no_end_if/test.txt
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
<@if(context.test)>
|
||||
test
|
||||
@@ -0,0 +1,7 @@
|
||||
<document>
|
||||
<@if(context.header.render)>
|
||||
<head> <@var(context.header.text)> </head>
|
||||
<@endif>
|
||||
<body>
|
||||
</body>
|
||||
</document>
|
||||
29
tsconfig.json
Normal file
29
tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["ESNext"],
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user