From 0e552ec4f5e28352c6c1196555f9b653f536e387 Mon Sep 17 00:00:00 2001 From: Gregor Lohaus Date: Wed, 8 Apr 2026 01:05:23 +0200 Subject: [PATCH] init --- .envrc | 12 + .gitignore | 45 +++ .helix/languages.toml | 4 + .ignore | 2 + CLAUDE.md | 106 +++++++ README.md | 128 ++++++++ bun.lock | 31 ++ devenv.lock | 123 ++++++++ devenv.nix | 7 + devenv.yaml | 15 + index.test.ts | 291 ++++++++++++++++++ index.ts | 120 ++++++++ package.json | 14 + parser.ts | 47 +++ render.ts | 123 ++++++++ .../<@if(context.file)>example.txt | 1 + testdata/if_elseif_else_in_file/test.txt | 7 + .../<@if(context.web)>web/if_example.html | 7 + testdata/no_end_if/test.txt | 2 + .../var_in_path_example.html | 7 + tsconfig.json | 29 ++ 21 files changed, 1121 insertions(+) create mode 100644 .envrc create mode 100644 .gitignore create mode 100644 .helix/languages.toml create mode 100644 .ignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 bun.lock create mode 100644 devenv.lock create mode 100644 devenv.nix create mode 100644 devenv.yaml create mode 100644 index.test.ts create mode 100644 index.ts create mode 100644 package.json create mode 100644 parser.ts create mode 100644 render.ts create mode 100644 testdata/file_if/<@if(context.web)>web/<@if(context.file)>example.txt create mode 100644 testdata/if_elseif_else_in_file/test.txt create mode 100644 testdata/if_example/<@if(context.web)>web/if_example.html create mode 100644 testdata/no_end_if/test.txt create mode 100644 testdata/var_in_path/<@if(context.web.create)><@var(context.web.dir)>/var_in_path_example.html create mode 100644 tsconfig.json diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..cc5c18b --- /dev/null +++ b/.envrc @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d14557f --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.helix/languages.toml b/.helix/languages.toml new file mode 100644 index 0000000..1813968 --- /dev/null +++ b/.helix/languages.toml @@ -0,0 +1,4 @@ +[[language]] +name="typescript" +roots = ["package.json"] +language-servers = ["typescript-language-server"] diff --git a/.ignore b/.ignore new file mode 100644 index 0000000..0b369a5 --- /dev/null +++ b/.ignore @@ -0,0 +1,2 @@ +.devenv +node_modules diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..764c1dd --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,106 @@ + +Default to using Bun instead of Node.js. + +- Use `bun ` instead of `node ` or `ts-node ` +- Use `bun test` instead of `jest` or `vitest` +- Use `bun build ` instead of `webpack` or `esbuild` +- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` +- Use `bun run + + +``` + +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

Hello, world!

; +} + +root.render(); +``` + +Then, run index.ts + +```sh +bun --hot ./index.ts +``` + +For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`. diff --git a/README.md b/README.md new file mode 100644 index 0000000..fa34dd5 --- /dev/null +++ b/README.md @@ -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 + + <@if(context.header.show)> + <@var(context.header.title)> + <@endif> + + +``` + +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 Hello +``` + +## 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> +``` diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..d809cfa --- /dev/null +++ b/bun.lock @@ -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=="], + } +} diff --git a/devenv.lock b/devenv.lock new file mode 100644 index 0000000..53a529d --- /dev/null +++ b/devenv.lock @@ -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 +} diff --git a/devenv.nix b/devenv.nix new file mode 100644 index 0000000..21e95ab --- /dev/null +++ b/devenv.nix @@ -0,0 +1,7 @@ +{ pkgs, lib, config, inputs, ... }: + +{ + packages = [ pkgs.bun ]; + languages.typescript.enable = true; + +} diff --git a/devenv.yaml b/devenv.yaml new file mode 100644 index 0000000..116a2ad --- /dev/null +++ b/devenv.yaml @@ -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 diff --git a/index.test.ts b/index.test.ts new file mode 100644 index 0000000..853fbd2 --- /dev/null +++ b/index.test.ts @@ -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("") + expect(content).toContain("") +}) + +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("") + expect(content).toContain("") +}) + +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("") + expect(content).toContain("") +}) + +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("") + expect(content).toContain("") +}) + +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") +}) + diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..55808eb --- /dev/null +++ b/index.ts @@ -0,0 +1,120 @@ +import { z } from "zod" +import { parse, type TemplateVariable } from "./parser" +import { renderDir } from "./render" + +const zodTypeMap: Record z.ZodType> = { + string: () => z.string(), + number: () => z.number(), + boolean: () => z.boolean(), +} + +type ShapeNode = { + type?: string + children: Map +} + +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 = {} + 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() + 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).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 = (userSchema: S) => { + validateSchemaMatchesTemplates(userSchema, variables) + return (targetPath: string, context: z.infer) => { + userSchema.parse(context) + renderDir(dirPath, targetPath, context as Record) + } + } + + return createRenderer +} + diff --git a/package.json b/package.json new file mode 100644 index 0000000..ad5371e --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/parser.ts b/parser.ts new file mode 100644 index 0000000..09bff9a --- /dev/null +++ b/parser.ts @@ -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() + return vars.filter(v => { + const key = `${v.path}:${v.type}` + if (seen.has(key)) return false + seen.add(key) + return true + }) +} diff --git a/render.ts b/render.ts new file mode 100644 index 0000000..c2dcc37 --- /dev/null +++ b/render.ts @@ -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, 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)[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 { + 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 { + 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, +) { + 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 } diff --git a/testdata/file_if/<@if(context.web)>web/<@if(context.file)>example.txt b/testdata/file_if/<@if(context.web)>web/<@if(context.file)>example.txt new file mode 100644 index 0000000..16ee9ab --- /dev/null +++ b/testdata/file_if/<@if(context.web)>web/<@if(context.file)>example.txt @@ -0,0 +1 @@ +<@var(context.text)> diff --git a/testdata/if_elseif_else_in_file/test.txt b/testdata/if_elseif_else_in_file/test.txt new file mode 100644 index 0000000..fe11d99 --- /dev/null +++ b/testdata/if_elseif_else_in_file/test.txt @@ -0,0 +1,7 @@ +<@if(context.test)> +test +<@elseif(context.test2)> +test2 +<@else> +test3 +<@endif> diff --git a/testdata/if_example/<@if(context.web)>web/if_example.html b/testdata/if_example/<@if(context.web)>web/if_example.html new file mode 100644 index 0000000..723a25f --- /dev/null +++ b/testdata/if_example/<@if(context.web)>web/if_example.html @@ -0,0 +1,7 @@ + + <@if(context.header.render)> + <@var(context.header.text)> + <@endif> + + + diff --git a/testdata/no_end_if/test.txt b/testdata/no_end_if/test.txt new file mode 100644 index 0000000..16b190d --- /dev/null +++ b/testdata/no_end_if/test.txt @@ -0,0 +1,2 @@ +<@if(context.test)> +test diff --git a/testdata/var_in_path/<@if(context.web.create)><@var(context.web.dir)>/var_in_path_example.html b/testdata/var_in_path/<@if(context.web.create)><@var(context.web.dir)>/var_in_path_example.html new file mode 100644 index 0000000..723a25f --- /dev/null +++ b/testdata/var_in_path/<@if(context.web.create)><@var(context.web.dir)>/var_in_path_example.html @@ -0,0 +1,7 @@ + + <@if(context.header.render)> + <@var(context.header.text)> + <@endif> + + + diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bfa0fea --- /dev/null +++ b/tsconfig.json @@ -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 + } +}