Compare commits
7
Commits
v0.1.8
...
101769a4b2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
101769a4b2 | ||
|
|
ed5a666ad3 | ||
|
|
452d872954 | ||
|
|
e900729c8f | ||
|
|
fe3489e217 | ||
|
|
9ed1324e06 | ||
|
|
d8536d83de |
@@ -7,7 +7,7 @@ on:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: x86
|
||||
runs-on: local
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -21,6 +21,10 @@ jobs:
|
||||
node-version: 24
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
# Stage-only tokens require `npm stage publish` (npm >= 11.15.0).
|
||||
- name: Update npm
|
||||
run: npm install -g npm@^11.15.0
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
@@ -30,10 +34,13 @@ jobs:
|
||||
TAG_NAME="${GITHUB_REF_NAME:-${GITHUB_REF#refs/tags/}}"
|
||||
test "$PACKAGE_VERSION" = "$TAG_NAME"
|
||||
|
||||
- name: Test
|
||||
run: bun test
|
||||
|
||||
- name: Build
|
||||
run: bun run build
|
||||
|
||||
- name: Publish
|
||||
run: npm publish --access public
|
||||
- name: Stage publish
|
||||
run: npm stage publish --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
@@ -61,6 +61,8 @@ render("./output", {
|
||||
| `<@if(context.x)>` | Conditional block — boolean check (must end with `<@endif>`) |
|
||||
| `<@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`) |
|
||||
| `<@else>` | Else branch |
|
||||
| `<@endif>` | End conditional block |
|
||||
@@ -74,6 +76,8 @@ render("./output", {
|
||||
| `<@if(context.x)>dirname` | Conditionally include directory/file (boolean check) |
|
||||
| `<@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 |
|
||||
|
||||
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.
|
||||
@@ -127,6 +131,31 @@ render("./output", { web: "not a boolean", header: { show: true, title: "Hi" } }
|
||||
|
||||
For safety, tdir refuses to render into the filesystem root, the current working directory, the home directory, or any directory that overlaps the template source. Dynamic file and directory names are also resolved against the output directory and cannot write outside it.
|
||||
|
||||
Pass `{ overwrite: false }` to keep existing work safe, e.g. in a scaffolding CLI. tdir then throws `TargetExistsError` instead of clearing a target that exists and is not empty (or is a file); a missing or empty target is rendered as usual:
|
||||
|
||||
```ts
|
||||
import { TargetExistsError } from "@gregorlohaus/tdir"
|
||||
|
||||
try {
|
||||
render("./my-app", context, { overwrite: false })
|
||||
} catch (error) {
|
||||
if (error instanceof TargetExistsError) console.error("./my-app already exists")
|
||||
else throw error
|
||||
}
|
||||
```
|
||||
|
||||
## Renaming output files
|
||||
|
||||
Some files can't be stored in a template under their real name, e.g. npm never publishes `.gitignore` or `.npmrc` files. Store them under another name and map it back with `rename`, which applies to rendered file and directory names:
|
||||
|
||||
```ts
|
||||
render("./output", context, { rename: { _gitignore: ".gitignore", _npmrc: ".npmrc" } })
|
||||
// templates/_gitignore -> output/.gitignore
|
||||
// templates/<@if(context.web)>web/_gitignore -> output/web/.gitignore
|
||||
```
|
||||
|
||||
Reverse maps record the renamed output path, so `tdir reverse` writes edits back to `_gitignore`.
|
||||
|
||||
## Reverse maps
|
||||
|
||||
Pass `{ reverseMap: true }` as the third render argument to write `.tdir-map.json` into the output directory:
|
||||
|
||||
+9
-9
@@ -3,11 +3,11 @@
|
||||
"devenv": {
|
||||
"locked": {
|
||||
"dir": "src/modules",
|
||||
"lastModified": 1776271913,
|
||||
"narHash": "sha256-j/1hNdZSci/jrYEHj3/F24EI/YE8DL0OzfMWZUgpMig=",
|
||||
"lastModified": 1790701375,
|
||||
"narHash": "sha256-0FpLHIRDYiyo3EMTnMQSobZQknZaa3JuWo6vmMCK76o=",
|
||||
"owner": "cachix",
|
||||
"repo": "devenv",
|
||||
"rev": "2012662a89ff2ce92044151d7bbf3894eec5620a",
|
||||
"rev": "fe20b5cba7ab5e93ae73f956a8d3efc50e1753f4",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -22,11 +22,11 @@
|
||||
"nixpkgs-src": "nixpkgs-src"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1776097194,
|
||||
"narHash": "sha256-XD4DsgNcfXC5nlCxlAcCP5hSjTYlgLXEIoTj7fKkQg4=",
|
||||
"lastModified": 1789506231,
|
||||
"narHash": "sha256-vNkJbtmqyBfFhn/HnzQo4ROuoyYWuy/allqTfJb4kMY=",
|
||||
"owner": "cachix",
|
||||
"repo": "devenv-nixpkgs",
|
||||
"rev": "6e8a07b02f6f8557ffab71274feac9827bcc2532",
|
||||
"rev": "c2f38fe7f9e04d9aadd354d380f2bd40531d9737",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -39,11 +39,11 @@
|
||||
"nixpkgs-src": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1775888245,
|
||||
"narHash": "sha256-nwASzrRDD1JBEu/o8ekKYEXm/oJW6EMCzCRdrwcLe90=",
|
||||
"lastModified": 1789370336,
|
||||
"narHash": "sha256-6RSEDHIWQtesQKWSu5qRai8L2h4KgCgMEfJHstW99G4=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "13043924aaa7375ce482ebe2494338e058282925",
|
||||
"rev": "c7def046b9a883d46974757852106483d741586f",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
+136
-2
@@ -1,5 +1,5 @@
|
||||
import { expect, test, beforeEach, afterEach } from 'bun:test'
|
||||
import { initRenderer, reverseDir, SchemaMismatchError } from "./index";
|
||||
import { initRenderer, reverseDir, SchemaMismatchError, TargetExistsError } from "./index";
|
||||
import { z } from "zod"
|
||||
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"
|
||||
import { basename, join } from "node:path"
|
||||
@@ -451,7 +451,7 @@ test("file if",() => {
|
||||
})
|
||||
|
||||
test("no endif should throw",() => {
|
||||
expect(() => initRenderer("./testdata/no_end_if")).toThrow()
|
||||
expect(() => initRenderer("./testdata/no_end_if")).toThrow("test.txt")
|
||||
})
|
||||
|
||||
test("path vars cannot write outside target",() => {
|
||||
@@ -590,3 +590,137 @@ test("if neq in path", () => {
|
||||
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)
|
||||
})
|
||||
|
||||
test("rename maps output names and records them in the reverse map", () => {
|
||||
const templateDir = join(tmp, "template")
|
||||
mkdirSync(join(templateDir, "<@if(context.web)>web"), { recursive: true })
|
||||
writeFileSync(join(templateDir, "_gitignore"), "node_modules\n")
|
||||
writeFileSync(join(templateDir, "<@if(context.web)>web", "_gitignore"), "dist\n")
|
||||
const render = initRenderer(templateDir)(z.object({ web: z.boolean() }))
|
||||
const out = join(tmp, "out")
|
||||
|
||||
render(out, { web: true }, { reverseMap: true, rename: { _gitignore: ".gitignore" } })
|
||||
|
||||
expect(readFileSync(join(out, ".gitignore"), "utf-8")).toBe("node_modules\n")
|
||||
expect(readFileSync(join(out, "web", ".gitignore"), "utf-8")).toBe("dist\n")
|
||||
expect(existsSync(join(out, "_gitignore"))).toBe(false)
|
||||
const manifest = JSON.parse(readFileSync(join(out, ".tdir-map.json"), "utf-8"))
|
||||
expect(manifest.files).toContainEqual(expect.objectContaining({ outputPath: ".gitignore", templatePath: "_gitignore" }))
|
||||
expect(manifest.files).toContainEqual(expect.objectContaining({
|
||||
outputPath: join("web", ".gitignore"),
|
||||
templatePath: join("<@if(context.web)>web", "_gitignore"),
|
||||
}))
|
||||
|
||||
const reversedDir = join(tmp, "reversed")
|
||||
reverseDir(out, reversedDir)
|
||||
expect(readFileSync(join(reversedDir, "_gitignore"), "utf-8")).toBe("node_modules\n")
|
||||
})
|
||||
|
||||
test("rename applies to directories", () => {
|
||||
const templateDir = join(tmp, "template")
|
||||
mkdirSync(join(templateDir, "_github"), { recursive: true })
|
||||
writeFileSync(join(templateDir, "_github", "ci.yml"), "on: push\n")
|
||||
const render = initRenderer(templateDir)(z.object({}))
|
||||
const out = join(tmp, "out")
|
||||
|
||||
render(out, {}, { rename: { _github: ".github" } })
|
||||
|
||||
expect(readFileSync(join(out, ".github", "ci.yml"), "utf-8")).toBe("on: push\n")
|
||||
})
|
||||
|
||||
test("overwrite false refuses a non-empty target and leaves it untouched", () => {
|
||||
const render = initRenderer("./testdata/if_example")(ifExampleSchema)
|
||||
const out = join(tmp, "out")
|
||||
mkdirSync(out)
|
||||
writeFileSync(join(out, "keep.txt"), "important")
|
||||
|
||||
expect(() => render(out, { web: true, header: { render: true, text: "T" } }, { overwrite: false })).toThrow(TargetExistsError)
|
||||
expect(readFileSync(join(out, "keep.txt"), "utf-8")).toBe("important")
|
||||
})
|
||||
|
||||
test("overwrite false renders into a missing or empty target", () => {
|
||||
const render = initRenderer("./testdata/if_example")(ifExampleSchema)
|
||||
const missing = join(tmp, "missing")
|
||||
const empty = join(tmp, "empty")
|
||||
mkdirSync(empty)
|
||||
|
||||
render(missing, { web: true, header: { render: true, text: "T" } }, { overwrite: false })
|
||||
render(empty, { web: true, header: { render: true, text: "T" } }, { overwrite: false })
|
||||
|
||||
expect(existsSync(join(missing, "web", "if_example.html"))).toBe(true)
|
||||
expect(existsSync(join(empty, "web", "if_example.html"))).toBe(true)
|
||||
})
|
||||
|
||||
test("overwrite false refuses when the target is a file", () => {
|
||||
const render = initRenderer("./testdata/if_example")(ifExampleSchema)
|
||||
const out = join(tmp, "out")
|
||||
writeFileSync(out, "not a directory")
|
||||
|
||||
expect(() => render(out, { web: true, header: { render: true, text: "T" } }, { overwrite: false })).toThrow(TargetExistsError)
|
||||
expect(readFileSync(out, "utf-8")).toBe("not a directory")
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from "zod"
|
||||
import { parse, type TemplateVariable } from "./parser"
|
||||
import { renderDir, type RenderOptions } from "./render"
|
||||
|
||||
export { TargetExistsError } from "./render"
|
||||
export type { RenderOptions, ReverseMapFile, ReverseMapManifest, ReverseMapStoredTemplate, ReverseMapToken } from "./render"
|
||||
export { reverseDir, type ReverseOptions, type ReverseResult, type ReverseWarning } from "./reverse"
|
||||
|
||||
|
||||
+6
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@gregorlohaus/tdir",
|
||||
"version": "0.1.8",
|
||||
"version": "0.3.0",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"bin": {
|
||||
"tdir": "./dist/cli.js"
|
||||
"tdir": "dist/cli.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
@@ -25,5 +25,9 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^4"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://gitea.gregorlohaus.com/gregor/tdir"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
import { readdirSync, statSync, readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { join, relative } from "node:path"
|
||||
import { TextDecoder } from "node:util"
|
||||
import { getDirectiveTokens, splitArgs } from "./scanner"
|
||||
|
||||
export type TemplateVariable = {
|
||||
path: string
|
||||
type: string
|
||||
}
|
||||
|
||||
const IF_RE = /<@(?:if|elseif)\((.+?)\)>/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 PATH_RE = /^context\.(.+)$/
|
||||
|
||||
function extractCondition(expr: string | undefined, vars: TemplateVariable[]) {
|
||||
if (!expr) throw new Error("Missing condition expression")
|
||||
for (const operator of ["and", "or"]) {
|
||||
const prefix = `${operator}(`
|
||||
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" })
|
||||
@@ -28,43 +36,50 @@ function extractCondition(expr: string | undefined, vars: TemplateVariable[]) {
|
||||
throw new Error(`Invalid condition expression: ${expr}`)
|
||||
}
|
||||
|
||||
function extractFromString(text: string, vars: TemplateVariable[]) {
|
||||
for (const match of text.matchAll(IF_RE)) {
|
||||
extractCondition(match[1]!, vars)
|
||||
function extractFromString(text: string, vars: TemplateVariable[], source = "template") {
|
||||
for (const token of getDirectiveTokens(text)) {
|
||||
if (token.type === "if" || token.type === "elseif") {
|
||||
try {
|
||||
extractCondition(token.condition, vars)
|
||||
} catch (error) {
|
||||
if (error instanceof Error) throw new Error(`${source}: ${error.message}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const match of text.matchAll(VAR_RE)) {
|
||||
vars.push({ path: match[1]!, type: match[2] ?? "string" })
|
||||
}
|
||||
}
|
||||
|
||||
function validateIfBlocks(content: string, vars: TemplateVariable[]) {
|
||||
function validateIfBlocks(content: string, vars: TemplateVariable[], source: string) {
|
||||
const stack: { sawElse: boolean }[] = []
|
||||
|
||||
for (const match of content.matchAll(DIRECTIVE_RE)) {
|
||||
const directive = match[1]!
|
||||
const condition = match[2]
|
||||
for (const token of getDirectiveTokens(content)) {
|
||||
const directive = token.type
|
||||
const condition = token.condition
|
||||
|
||||
if (directive === "if") {
|
||||
extractCondition(condition!, vars)
|
||||
stack.push({ sawElse: false })
|
||||
} else if (directive === "elseif") {
|
||||
const frame = stack[stack.length - 1]
|
||||
if (!frame) throw new Error("Unexpected <@elseif> without <@if>")
|
||||
if (frame.sawElse) throw new Error("Unexpected <@elseif> after <@else>")
|
||||
if (!frame) throw new Error(`${source}: Unexpected <@elseif> without <@if>`)
|
||||
if (frame.sawElse) throw new Error(`${source}: Unexpected <@elseif> after <@else>`)
|
||||
extractCondition(condition!, vars)
|
||||
} else if (directive === "else") {
|
||||
const frame = stack[stack.length - 1]
|
||||
if (!frame) throw new Error("Unexpected <@else> without <@if>")
|
||||
if (frame.sawElse) throw new Error("Unexpected duplicate <@else>")
|
||||
if (!frame) throw new Error(`${source}: Unexpected <@else> without <@if>`)
|
||||
if (frame.sawElse) throw new Error(`${source}: Unexpected duplicate <@else>`)
|
||||
frame.sawElse = true
|
||||
} else if (directive === "endif") {
|
||||
if (stack.length === 0) throw new Error("Unexpected <@endif> without <@if>")
|
||||
if (stack.length === 0) throw new Error(`${source}: Unexpected <@endif> without <@if>`)
|
||||
stack.pop()
|
||||
}
|
||||
}
|
||||
|
||||
if (stack.length > 0) {
|
||||
throw new Error("Unmatched <@if> without <@endif>")
|
||||
throw new Error(`${source}: Unmatched <@if> without <@endif>`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,21 +93,22 @@ function isUtf8Text(buffer: Buffer): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function walkDir(dirPath: string, vars: TemplateVariable[]) {
|
||||
function walkDir(dirPath: string, vars: TemplateVariable[], rootPath: string) {
|
||||
const entries = readdirSync(dirPath).sort()
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dirPath, entry)
|
||||
extractFromString(entry, vars)
|
||||
const relativePath = relative(rootPath, fullPath)
|
||||
extractFromString(entry, vars, relativePath || entry)
|
||||
|
||||
const stat = statSync(fullPath)
|
||||
if (stat.isDirectory()) {
|
||||
walkDir(fullPath, vars)
|
||||
walkDir(fullPath, vars, rootPath)
|
||||
} else if (stat.isFile()) {
|
||||
const content = readFileSync(fullPath)
|
||||
if (isUtf8Text(content)) {
|
||||
const text = content.toString("utf-8")
|
||||
extractFromString(text, vars)
|
||||
validateIfBlocks(text, vars)
|
||||
extractFromString(text, vars, relativePath || fullPath)
|
||||
validateIfBlocks(text, vars, relativePath || fullPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,7 +116,7 @@ function walkDir(dirPath: string, vars: TemplateVariable[]) {
|
||||
|
||||
export function parse(dirPath: string): TemplateVariable[] {
|
||||
const vars: TemplateVariable[] = []
|
||||
walkDir(dirPath, vars)
|
||||
walkDir(dirPath, vars, dirPath)
|
||||
const seen = new Set<string>()
|
||||
return vars.filter(v => {
|
||||
const key = `${v.path}:${v.type}`
|
||||
|
||||
@@ -10,10 +10,10 @@ import {
|
||||
import { dirname, isAbsolute, relative, resolve as resolvePath } from "node:path"
|
||||
import { homedir } from "node:os"
|
||||
import { TextDecoder } from "node:util"
|
||||
import { getDirectiveTokens, splitArgs, type DirectiveToken } from "./scanner"
|
||||
|
||||
const IF_PATH_RE = /^<@if\((.+?)\)>(.*)$/
|
||||
const VAR_RE = /<@var\(context\.(.+?)(?::(\w+))?\)>/g
|
||||
const DIRECTIVE_RE = /<@(if|elseif|else|endif)(?:\((.+?)\))?>/g
|
||||
const STRING_COMPARE_RE = /^(eq|neq)\(context\.(.+?),\s*"(.*)"\)$/
|
||||
const PATH_RE = /^context\.(.+)$/
|
||||
|
||||
@@ -58,16 +58,38 @@ export type ReverseMapManifest = {
|
||||
|
||||
export type RenderOptions = {
|
||||
reverseMap?: boolean | string
|
||||
/** When false, throw TargetExistsError instead of clearing a non-empty target. Defaults to true. */
|
||||
overwrite?: boolean
|
||||
/** Renames rendered file and directory names, e.g. `{ _gitignore: ".gitignore" }`. */
|
||||
rename?: Record<string, string>
|
||||
}
|
||||
|
||||
export class TargetExistsError extends Error {
|
||||
constructor(target: string) {
|
||||
super(`Refusing to overwrite non-empty target directory: ${target}`)
|
||||
this.name = "TargetExistsError"
|
||||
}
|
||||
}
|
||||
|
||||
type RenderState = {
|
||||
sourceRoot: string
|
||||
destRoot: string
|
||||
manifest?: ReverseMapManifest
|
||||
rename: Record<string, string>
|
||||
}
|
||||
|
||||
function evalCondition(expr: string | undefined, context: Record<string, unknown>): boolean {
|
||||
if (!expr) throw new Error("Missing condition expression")
|
||||
for (const operator of ["and", "or"] as const) {
|
||||
const prefix = `${operator}(`
|
||||
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]
|
||||
@@ -159,6 +181,7 @@ function getReverseMapPath(destRoot: string, reverseMap: true | string): string
|
||||
function getOutputName(
|
||||
entry: string,
|
||||
context: Record<string, unknown>,
|
||||
rename: Record<string, string>,
|
||||
state?: RenderState,
|
||||
file?: ReverseMapFile,
|
||||
): string | null {
|
||||
@@ -178,7 +201,7 @@ function getOutputName(
|
||||
}
|
||||
}
|
||||
|
||||
return outputName.replace(VAR_RE, (_match, path: string) => {
|
||||
const rendered = outputName.replace(VAR_RE, (_match, path: string) => {
|
||||
const result = String(resolveContext(context, path) ?? "")
|
||||
addReverseMapToken(state, file, {
|
||||
kind: "path",
|
||||
@@ -190,6 +213,15 @@ function getOutputName(
|
||||
})
|
||||
return result
|
||||
})
|
||||
return Object.hasOwn(rename, rendered) ? rename[rendered]! : rendered
|
||||
}
|
||||
|
||||
function isNonEmptyTarget(destRoot: string): boolean {
|
||||
try {
|
||||
return !statSync(destRoot).isDirectory() || readdirSync(destRoot).length > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isUtf8Text(buffer: Buffer): boolean {
|
||||
@@ -202,13 +234,6 @@ function isUtf8Text(buffer: Buffer): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
type DirectiveToken = {
|
||||
type: "if" | "elseif" | "else" | "endif"
|
||||
condition?: string
|
||||
index: number
|
||||
end: number
|
||||
}
|
||||
|
||||
type TextNode = {
|
||||
type: "text"
|
||||
text: string
|
||||
@@ -245,15 +270,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(
|
||||
content: string,
|
||||
tokens: DirectiveToken[],
|
||||
@@ -456,10 +472,14 @@ function renderDir(
|
||||
sourceRoot,
|
||||
destRoot,
|
||||
manifest: options.reverseMap ? createReverseMapManifest() : undefined,
|
||||
rename: options.rename ?? {},
|
||||
}
|
||||
const mapPath = options.reverseMap ? getReverseMapPath(destRoot, options.reverseMap) : undefined
|
||||
assertSafeRenderTarget(srcDir, destDir)
|
||||
validateOutputPaths(srcDir, destRoot, context)
|
||||
validateOutputPaths(srcDir, destRoot, context, state.rename)
|
||||
if (options.overwrite === false && isNonEmptyTarget(destRoot)) {
|
||||
throw new TargetExistsError(destDir)
|
||||
}
|
||||
rmSync(destDir, { recursive: true, force: true })
|
||||
renderDirInner(srcDir, destRoot, context, state)
|
||||
if (state.manifest && mapPath) {
|
||||
@@ -472,18 +492,19 @@ function validateOutputPaths(
|
||||
srcDir: string,
|
||||
destDir: string,
|
||||
context: Record<string, unknown>,
|
||||
rename: Record<string, string>,
|
||||
) {
|
||||
const entries = readdirSync(srcDir).sort()
|
||||
|
||||
for (const entry of entries) {
|
||||
const srcPath = resolvePath(srcDir, entry)
|
||||
const stat = statSync(srcPath)
|
||||
const outputName = getOutputName(entry, context)
|
||||
const outputName = getOutputName(entry, context, rename)
|
||||
if (outputName === null) continue
|
||||
|
||||
const destPath = resolveOutputPath(destDir, outputName)
|
||||
if (stat.isDirectory()) {
|
||||
validateOutputPaths(srcPath, destPath, context)
|
||||
validateOutputPaths(srcPath, destPath, context, rename)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -539,7 +560,7 @@ function renderDirInner(
|
||||
templatePath,
|
||||
tokens: [],
|
||||
}
|
||||
const outputName = getOutputName(entry, context, state, tempFile)
|
||||
const outputName = getOutputName(entry, context, state.rename, state, tempFile)
|
||||
if (outputName === null) {
|
||||
storeSkippedTemplate(srcPath, state)
|
||||
continue
|
||||
|
||||
+99
@@ -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
|
||||
}
|
||||
Vendored
+3
@@ -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>
|
||||
+1
@@ -0,0 +1 @@
|
||||
match
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
<@if(neq(context.project.frontend,"none"))>
|
||||
bundev = {
|
||||
exec = "bun dev";
|
||||
cwd = "./apps/web";
|
||||
after= ["devenv:processes:air@started"];
|
||||
};
|
||||
<@endif>
|
||||
+1
-1
@@ -6,5 +6,5 @@
|
||||
"emitDeclarationOnly": true,
|
||||
"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