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