Compare commits
4 Commits
v0.1.7
...
fe3489e217
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe3489e217 | ||
|
|
9ed1324e06 | ||
|
|
d8536d83de | ||
|
|
0fab9c8d38 |
@@ -60,6 +60,9 @@ render("./output", {
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `<@if(context.x)>` | Conditional block — boolean check (must end with `<@endif>`) |
|
| `<@if(context.x)>` | Conditional block — boolean check (must end with `<@endif>`) |
|
||||||
| `<@if(eq(context.x,"value"))>` | Conditional block — string equality check |
|
| `<@if(eq(context.x,"value"))>` | Conditional block — string equality check |
|
||||||
|
| `<@if(neq(context.x,"value"))>` | Conditional block — string inequality check |
|
||||||
|
| `<@if(and(context.x,eq(context.y,"value")))>` | Conditional block — all child conditions must match |
|
||||||
|
| `<@if(or(context.x,eq(context.y,"value")))>` | Conditional block — any child condition may match |
|
||||||
| `<@elseif(context.y)>` | Else-if branch (same forms as `@if`) |
|
| `<@elseif(context.y)>` | Else-if branch (same forms as `@if`) |
|
||||||
| `<@else>` | Else branch |
|
| `<@else>` | Else branch |
|
||||||
| `<@endif>` | End conditional block |
|
| `<@endif>` | End conditional block |
|
||||||
@@ -72,6 +75,9 @@ render("./output", {
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `<@if(context.x)>dirname` | Conditionally include directory/file (boolean check) |
|
| `<@if(context.x)>dirname` | Conditionally include directory/file (boolean check) |
|
||||||
| `<@if(eq(context.x,"value"))>dirname` | Conditionally include by string equality |
|
| `<@if(eq(context.x,"value"))>dirname` | Conditionally include by string equality |
|
||||||
|
| `<@if(neq(context.x,"value"))>dirname` | Conditionally include by string inequality |
|
||||||
|
| `<@if(and(context.x,eq(context.y,"value")))>dirname` | Conditionally include by combined conditions |
|
||||||
|
| `<@if(or(context.x,eq(context.y,"value")))>dirname` | Conditionally include by alternate conditions |
|
||||||
| `<@var(context.x)>` | Dynamic directory/file name |
|
| `<@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.
|
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.
|
||||||
|
|||||||
@@ -568,3 +568,90 @@ test("if eq in path", () => {
|
|||||||
render(tmp,{test:"foo"})
|
render(tmp,{test:"foo"})
|
||||||
expect(existsSync(join(tmp,"test"))).toBe(false)
|
expect(existsSync(join(tmp,"test"))).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("if neq in file", () => {
|
||||||
|
const createRenderer = initRenderer("./testdata/neq_in_file")
|
||||||
|
expect(() => createRenderer(z.object({test: z.boolean()}))).toThrow(SchemaMismatchError)
|
||||||
|
const render = createRenderer(z.object({test: z.string()}))
|
||||||
|
|
||||||
|
render(tmp,{test:"foo"})
|
||||||
|
expect(readFileSync(join(tmp,"test.txt"), "utf-8")).toContain("not-test")
|
||||||
|
|
||||||
|
render(tmp,{test:"test"})
|
||||||
|
expect(readFileSync(join(tmp,"test.txt"), "utf-8")).toContain("test")
|
||||||
|
expect(readFileSync(join(tmp,"test.txt"), "utf-8")).not.toContain("not-test")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("if neq in path", () => {
|
||||||
|
const createRenderer = initRenderer("./testdata/neq_in_path")
|
||||||
|
const render = createRenderer(z.object({test: z.string()}))
|
||||||
|
render(tmp,{test:"foo"})
|
||||||
|
expect(existsSync(join(tmp,"not-test"))).toBe(true)
|
||||||
|
render(tmp,{test:"test"})
|
||||||
|
expect(existsSync(join(tmp,"not-test"))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("if neq multiline block", () => {
|
||||||
|
const createRenderer = initRenderer("./testdata/neq_multiline_block")
|
||||||
|
const render = createRenderer(z.object({
|
||||||
|
project: z.object({
|
||||||
|
frontend: z.string()
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
render(tmp,{project:{frontend:"react"}})
|
||||||
|
expect(readFileSync(join(tmp,"test.txt"), "utf-8")).toContain("bun dev")
|
||||||
|
|
||||||
|
render(tmp,{project:{frontend:"none"}})
|
||||||
|
expect(readFileSync(join(tmp,"test.txt"), "utf-8")).not.toContain("bun dev")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("and or in file", () => {
|
||||||
|
const createRenderer = initRenderer("./testdata/and_or_in_file")
|
||||||
|
expect(() => createRenderer(z.object({
|
||||||
|
enabled: z.boolean(),
|
||||||
|
fallback: z.boolean(),
|
||||||
|
kind: z.boolean()
|
||||||
|
}))).toThrow(SchemaMismatchError)
|
||||||
|
|
||||||
|
const render = createRenderer(z.object({
|
||||||
|
enabled: z.boolean(),
|
||||||
|
fallback: z.boolean(),
|
||||||
|
kind: z.string()
|
||||||
|
}))
|
||||||
|
|
||||||
|
render(tmp,{enabled:true, fallback:false, kind:"web"})
|
||||||
|
let content = readFileSync(join(tmp,"test.txt"), "utf-8")
|
||||||
|
expect(content).toContain("and")
|
||||||
|
expect(content).toContain("or")
|
||||||
|
expect(content).toContain("nested")
|
||||||
|
|
||||||
|
render(tmp,{enabled:false, fallback:true, kind:"web"})
|
||||||
|
content = readFileSync(join(tmp,"test.txt"), "utf-8")
|
||||||
|
expect(content).toContain("not-and")
|
||||||
|
expect(content).toContain("not-or")
|
||||||
|
expect(content).toContain("nested")
|
||||||
|
|
||||||
|
render(tmp,{enabled:false, fallback:true, kind:"docs"})
|
||||||
|
content = readFileSync(join(tmp,"test.txt"), "utf-8")
|
||||||
|
expect(content).toContain("not-and")
|
||||||
|
expect(content).toContain("or")
|
||||||
|
expect(content).toContain("nested")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("and or in path", () => {
|
||||||
|
const createRenderer = initRenderer("./testdata/and_or_in_path")
|
||||||
|
const render = createRenderer(z.object({
|
||||||
|
enabled: z.boolean(),
|
||||||
|
kind: z.string()
|
||||||
|
}))
|
||||||
|
|
||||||
|
render(tmp,{enabled:true, kind:"web"})
|
||||||
|
expect(existsSync(join(tmp,"match"))).toBe(true)
|
||||||
|
|
||||||
|
render(tmp,{enabled:true, kind:"docs"})
|
||||||
|
expect(existsSync(join(tmp,"match"))).toBe(true)
|
||||||
|
|
||||||
|
render(tmp,{enabled:false, kind:"docs"})
|
||||||
|
expect(existsSync(join(tmp,"match"))).toBe(false)
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@gregorlohaus/tdir",
|
"name": "@gregorlohaus/tdir",
|
||||||
"version": "0.1.7",
|
"version": "0.1.9",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
|
|||||||
32
parser.ts
32
parser.ts
@@ -1,23 +1,31 @@
|
|||||||
import { readdirSync, statSync, readFileSync } from "node:fs"
|
import { readdirSync, statSync, readFileSync } from "node:fs"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { TextDecoder } from "node:util"
|
import { TextDecoder } from "node:util"
|
||||||
|
import { getDirectiveTokens, splitArgs } from "./scanner"
|
||||||
|
|
||||||
export type TemplateVariable = {
|
export type TemplateVariable = {
|
||||||
path: string
|
path: string
|
||||||
type: string
|
type: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const IF_RE = /<@(?:if|elseif)\((.+?)\)>/g
|
|
||||||
const VAR_RE = /<@var\(context\.(.+?)(?::(\w+))?\)>/g
|
const VAR_RE = /<@var\(context\.(.+?)(?::(\w+))?\)>/g
|
||||||
const DIRECTIVE_RE = /<@(if|elseif|else|endif)(?:\((.+?)\))?>/g
|
const STRING_COMPARE_RE = /^(?:eq|neq)\(context\.(.+?),\s*"(.*)"\)$/
|
||||||
const EQ_RE = /^eq\(context\.(.+?),\s*"(.*)"\)$/
|
|
||||||
const PATH_RE = /^context\.(.+)$/
|
const PATH_RE = /^context\.(.+)$/
|
||||||
|
|
||||||
function extractCondition(expr: string | undefined, vars: TemplateVariable[]) {
|
function extractCondition(expr: string | undefined, vars: TemplateVariable[]) {
|
||||||
if (!expr) throw new Error("Missing condition expression")
|
if (!expr) throw new Error("Missing condition expression")
|
||||||
const eqMatch = expr.match(EQ_RE)
|
for (const operator of ["and", "or"]) {
|
||||||
if (eqMatch) {
|
const prefix = `${operator}(`
|
||||||
vars.push({ path: eqMatch[1]!, type: "string" })
|
if (expr.startsWith(prefix) && expr.endsWith(")")) {
|
||||||
|
const args = splitArgs(expr.slice(prefix.length, -1))
|
||||||
|
if (args.length === 0) throw new Error(`Invalid condition expression: ${expr}`)
|
||||||
|
for (const arg of args) extractCondition(arg, vars)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const stringCompareMatch = expr.match(STRING_COMPARE_RE)
|
||||||
|
if (stringCompareMatch) {
|
||||||
|
vars.push({ path: stringCompareMatch[1]!, type: "string" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const pathMatch = expr.match(PATH_RE)
|
const pathMatch = expr.match(PATH_RE)
|
||||||
@@ -29,8 +37,10 @@ function extractCondition(expr: string | undefined, vars: TemplateVariable[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function extractFromString(text: string, vars: TemplateVariable[]) {
|
function extractFromString(text: string, vars: TemplateVariable[]) {
|
||||||
for (const match of text.matchAll(IF_RE)) {
|
for (const token of getDirectiveTokens(text)) {
|
||||||
extractCondition(match[1]!, vars)
|
if (token.type === "if" || token.type === "elseif") {
|
||||||
|
extractCondition(token.condition, vars)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (const match of text.matchAll(VAR_RE)) {
|
for (const match of text.matchAll(VAR_RE)) {
|
||||||
vars.push({ path: match[1]!, type: match[2] ?? "string" })
|
vars.push({ path: match[1]!, type: match[2] ?? "string" })
|
||||||
@@ -40,9 +50,9 @@ function extractFromString(text: string, vars: TemplateVariable[]) {
|
|||||||
function validateIfBlocks(content: string, vars: TemplateVariable[]) {
|
function validateIfBlocks(content: string, vars: TemplateVariable[]) {
|
||||||
const stack: { sawElse: boolean }[] = []
|
const stack: { sawElse: boolean }[] = []
|
||||||
|
|
||||||
for (const match of content.matchAll(DIRECTIVE_RE)) {
|
for (const token of getDirectiveTokens(content)) {
|
||||||
const directive = match[1]!
|
const directive = token.type
|
||||||
const condition = match[2]
|
const condition = token.condition
|
||||||
|
|
||||||
if (directive === "if") {
|
if (directive === "if") {
|
||||||
extractCondition(condition!, vars)
|
extractCondition(condition!, vars)
|
||||||
|
|||||||
37
render.ts
37
render.ts
@@ -10,11 +10,11 @@ import {
|
|||||||
import { dirname, isAbsolute, relative, resolve as resolvePath } from "node:path"
|
import { dirname, isAbsolute, relative, resolve as resolvePath } from "node:path"
|
||||||
import { homedir } from "node:os"
|
import { homedir } from "node:os"
|
||||||
import { TextDecoder } from "node:util"
|
import { TextDecoder } from "node:util"
|
||||||
|
import { getDirectiveTokens, splitArgs, type DirectiveToken } from "./scanner"
|
||||||
|
|
||||||
const IF_PATH_RE = /^<@if\((.+?)\)>(.*)$/
|
const IF_PATH_RE = /^<@if\((.+?)\)>(.*)$/
|
||||||
const VAR_RE = /<@var\(context\.(.+?)(?::(\w+))?\)>/g
|
const VAR_RE = /<@var\(context\.(.+?)(?::(\w+))?\)>/g
|
||||||
const DIRECTIVE_RE = /<@(if|elseif|else|endif)(?:\((.+?)\))?>/g
|
const STRING_COMPARE_RE = /^(eq|neq)\(context\.(.+?),\s*"(.*)"\)$/
|
||||||
const EQ_RE = /^eq\(context\.(.+?),\s*"(.*)"\)$/
|
|
||||||
const PATH_RE = /^context\.(.+)$/
|
const PATH_RE = /^context\.(.+)$/
|
||||||
|
|
||||||
export type ReverseMapToken = {
|
export type ReverseMapToken = {
|
||||||
@@ -68,9 +68,20 @@ type RenderState = {
|
|||||||
|
|
||||||
function evalCondition(expr: string | undefined, context: Record<string, unknown>): boolean {
|
function evalCondition(expr: string | undefined, context: Record<string, unknown>): boolean {
|
||||||
if (!expr) throw new Error("Missing condition expression")
|
if (!expr) throw new Error("Missing condition expression")
|
||||||
const eqMatch = expr.match(EQ_RE)
|
for (const operator of ["and", "or"] as const) {
|
||||||
if (eqMatch) {
|
const prefix = `${operator}(`
|
||||||
return resolveContext(context, eqMatch[1]!) === eqMatch[2]
|
if (expr.startsWith(prefix) && expr.endsWith(")")) {
|
||||||
|
const args = splitArgs(expr.slice(prefix.length, -1))
|
||||||
|
if (args.length === 0) throw new Error(`Invalid condition expression: ${expr}`)
|
||||||
|
return operator === "and"
|
||||||
|
? args.every(arg => evalCondition(arg, context))
|
||||||
|
: args.some(arg => evalCondition(arg, context))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const stringCompareMatch = expr.match(STRING_COMPARE_RE)
|
||||||
|
if (stringCompareMatch) {
|
||||||
|
const result = resolveContext(context, stringCompareMatch[2]!) === stringCompareMatch[3]
|
||||||
|
return stringCompareMatch[1] === "eq" ? result : !result
|
||||||
}
|
}
|
||||||
const pathMatch = expr.match(PATH_RE)
|
const pathMatch = expr.match(PATH_RE)
|
||||||
if (pathMatch) {
|
if (pathMatch) {
|
||||||
@@ -201,13 +212,6 @@ function isUtf8Text(buffer: Buffer): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type DirectiveToken = {
|
|
||||||
type: "if" | "elseif" | "else" | "endif"
|
|
||||||
condition?: string
|
|
||||||
index: number
|
|
||||||
end: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type TextNode = {
|
type TextNode = {
|
||||||
type: "text"
|
type: "text"
|
||||||
text: string
|
text: string
|
||||||
@@ -244,15 +248,6 @@ type ConditionalRender = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDirectiveTokens(content: string): DirectiveToken[] {
|
|
||||||
return Array.from(content.matchAll(DIRECTIVE_RE), match => ({
|
|
||||||
type: match[1] as DirectiveToken["type"],
|
|
||||||
condition: match[2],
|
|
||||||
index: match.index!,
|
|
||||||
end: match.index! + match[0].length,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseNodes(
|
function parseNodes(
|
||||||
content: string,
|
content: string,
|
||||||
tokens: DirectiveToken[],
|
tokens: DirectiveToken[],
|
||||||
|
|||||||
99
scanner.ts
Normal file
99
scanner.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
export type DirectiveToken = {
|
||||||
|
type: "if" | "elseif" | "else" | "endif"
|
||||||
|
condition?: string
|
||||||
|
index: number
|
||||||
|
end: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCondition(text: string, start: number): { condition: string; end: number } | null {
|
||||||
|
let depth = 0
|
||||||
|
let inString = false
|
||||||
|
let escaped = false
|
||||||
|
|
||||||
|
for (let i = start; i < text.length; i++) {
|
||||||
|
const char = text[i]!
|
||||||
|
if (escaped) {
|
||||||
|
escaped = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (char === "\\") {
|
||||||
|
escaped = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (char === "\"") {
|
||||||
|
inString = !inString
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (inString) continue
|
||||||
|
if (char === "(") {
|
||||||
|
depth += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (char === ")") {
|
||||||
|
depth -= 1
|
||||||
|
if (depth === 0 && text[i + 1] === ">") {
|
||||||
|
return { condition: text.slice(start + 1, i), end: i + 2 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDirectiveTokens(text: string): DirectiveToken[] {
|
||||||
|
const tokens: DirectiveToken[] = []
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
if (text[i] !== "<" || text[i + 1] !== "@") continue
|
||||||
|
const rest = text.slice(i + 2)
|
||||||
|
const type = ["elseif", "endif", "else", "if"].find(name => rest.startsWith(name)) as DirectiveToken["type"] | undefined
|
||||||
|
if (!type) continue
|
||||||
|
const afterName = i + 2 + type.length
|
||||||
|
if ((type === "if" || type === "elseif") && text[afterName] === "(") {
|
||||||
|
const parsed = readCondition(text, afterName)
|
||||||
|
if (!parsed) continue
|
||||||
|
tokens.push({ type, condition: parsed.condition, index: i, end: parsed.end })
|
||||||
|
i = parsed.end - 1
|
||||||
|
} else if ((type === "else" || type === "endif") && text[afterName] === ">") {
|
||||||
|
tokens.push({ type, index: i, end: afterName + 1 })
|
||||||
|
i = afterName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitArgs(args: string): string[] {
|
||||||
|
const result: string[] = []
|
||||||
|
let current = ""
|
||||||
|
let depth = 0
|
||||||
|
let inString = false
|
||||||
|
let escaped = false
|
||||||
|
|
||||||
|
for (const char of args) {
|
||||||
|
if (escaped) {
|
||||||
|
current += char
|
||||||
|
escaped = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (char === "\\") {
|
||||||
|
current += char
|
||||||
|
escaped = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (char === "\"") {
|
||||||
|
current += char
|
||||||
|
inString = !inString
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!inString && char === "(") depth += 1
|
||||||
|
if (!inString && char === ")") depth -= 1
|
||||||
|
if (!inString && depth === 0 && char === ",") {
|
||||||
|
result.push(current.trim())
|
||||||
|
current = ""
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
current += char
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.trim() !== "") result.push(current.trim())
|
||||||
|
return result
|
||||||
|
}
|
||||||
3
testdata/and_or_in_file/test.txt
vendored
Normal file
3
testdata/and_or_in_file/test.txt
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<@if(and(context.enabled,eq(context.kind,"web")))>and<@else>not-and<@endif>
|
||||||
|
<@if(or(context.enabled,neq(context.kind,"web")))>or<@else>not-or<@endif>
|
||||||
|
<@if(and(or(context.enabled,context.fallback),neq(context.kind,"native")))>nested<@else>not-nested<@endif>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
match
|
||||||
1
testdata/neq_in_file/test.txt
vendored
Normal file
1
testdata/neq_in_file/test.txt
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<@if(neq(context.test,"test"))>not-test<@else>test<@endif>
|
||||||
1
testdata/neq_in_path/<@if(neq(context.test,"test"))>not-test
vendored
Normal file
1
testdata/neq_in_path/<@if(neq(context.test,"test"))>not-test
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
not test
|
||||||
7
testdata/neq_multiline_block/test.txt
vendored
Normal file
7
testdata/neq_multiline_block/test.txt
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<@if(neq(context.project.frontend,"none"))>
|
||||||
|
bundev = {
|
||||||
|
exec = "bun dev";
|
||||||
|
cwd = "./apps/web";
|
||||||
|
after= ["devenv:processes:air@started"];
|
||||||
|
};
|
||||||
|
<@endif>
|
||||||
@@ -6,5 +6,5 @@
|
|||||||
"emitDeclarationOnly": true,
|
"emitDeclarationOnly": true,
|
||||||
"outDir": "./dist"
|
"outDir": "./dist"
|
||||||
},
|
},
|
||||||
"include": ["index.ts", "parser.ts", "render.ts", "reverse.ts"]
|
"include": ["index.ts", "parser.ts", "render.ts", "reverse.ts", "scanner.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user