commit 0e552ec4f5e28352c6c1196555f9b653f536e387 Author: Gregor Lohaus Date: Wed Apr 8 01:05:23 2026 +0200 init 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 + } +}