3 Commits
Author SHA1 Message Date
gregor 101769a4b2 runonlocal
Publish npm package / publish (push) Successful in 26s
2026-10-08 09:10:19 +02:00
gregor ed5a666ad3 0.3.0
Publish npm package / publish (push) Canceled after 0s
2026-10-07 15:30:39 +02:00
Gregor Lohaus 452d872954 meta, repo url in package.json
Publish npm package / publish (push) Successful in 30s
2026-05-26 09:28:05 +02:00
11 changed files with 153 additions and 20 deletions
+10 -3
View File
@@ -7,7 +7,7 @@ on:
jobs: jobs:
publish: publish:
runs-on: x86 runs-on: local
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -21,6 +21,10 @@ jobs:
node-version: 24 node-version: 24
registry-url: "https://registry.npmjs.org" 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 - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
@@ -30,10 +34,13 @@ jobs:
TAG_NAME="${GITHUB_REF_NAME:-${GITHUB_REF#refs/tags/}}" TAG_NAME="${GITHUB_REF_NAME:-${GITHUB_REF#refs/tags/}}"
test "$PACKAGE_VERSION" = "$TAG_NAME" test "$PACKAGE_VERSION" = "$TAG_NAME"
- name: Test
run: bun test
- name: Build - name: Build
run: bun run build run: bun run build
- name: Publish - name: Stage publish
run: npm publish --access public run: npm stage publish --access public
env: env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+25
View File
@@ -131,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. 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 ## Reverse maps
Pass `{ reverseMap: true }` as the third render argument to write `.tdir-map.json` into the output directory: Pass `{ reverseMap: true }` as the third render argument to write `.tdir-map.json` into the output directory:
+9 -9
View File
@@ -3,11 +3,11 @@
"devenv": { "devenv": {
"locked": { "locked": {
"dir": "src/modules", "dir": "src/modules",
"lastModified": 1776271913, "lastModified": 1790701375,
"narHash": "sha256-j/1hNdZSci/jrYEHj3/F24EI/YE8DL0OzfMWZUgpMig=", "narHash": "sha256-0FpLHIRDYiyo3EMTnMQSobZQknZaa3JuWo6vmMCK76o=",
"owner": "cachix", "owner": "cachix",
"repo": "devenv", "repo": "devenv",
"rev": "2012662a89ff2ce92044151d7bbf3894eec5620a", "rev": "fe20b5cba7ab5e93ae73f956a8d3efc50e1753f4",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -22,11 +22,11 @@
"nixpkgs-src": "nixpkgs-src" "nixpkgs-src": "nixpkgs-src"
}, },
"locked": { "locked": {
"lastModified": 1776097194, "lastModified": 1789506231,
"narHash": "sha256-XD4DsgNcfXC5nlCxlAcCP5hSjTYlgLXEIoTj7fKkQg4=", "narHash": "sha256-vNkJbtmqyBfFhn/HnzQo4ROuoyYWuy/allqTfJb4kMY=",
"owner": "cachix", "owner": "cachix",
"repo": "devenv-nixpkgs", "repo": "devenv-nixpkgs",
"rev": "6e8a07b02f6f8557ffab71274feac9827bcc2532", "rev": "c2f38fe7f9e04d9aadd354d380f2bd40531d9737",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -39,11 +39,11 @@
"nixpkgs-src": { "nixpkgs-src": {
"flake": false, "flake": false,
"locked": { "locked": {
"lastModified": 1775888245, "lastModified": 1789370336,
"narHash": "sha256-nwASzrRDD1JBEu/o8ekKYEXm/oJW6EMCzCRdrwcLe90=", "narHash": "sha256-6RSEDHIWQtesQKWSu5qRai8L2h4KgCgMEfJHstW99G4=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "13043924aaa7375ce482ebe2494338e058282925", "rev": "c7def046b9a883d46974757852106483d741586f",
"type": "github" "type": "github"
}, },
"original": { "original": {
+70 -1
View File
@@ -1,5 +1,5 @@
import { expect, test, beforeEach, afterEach } from 'bun:test' 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 { z } from "zod"
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs" import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"
import { basename, join } from "node:path" import { basename, join } from "node:path"
@@ -655,3 +655,72 @@ test("and or in path", () => {
render(tmp,{enabled:false, kind:"docs"}) render(tmp,{enabled:false, kind:"docs"})
expect(existsSync(join(tmp,"match"))).toBe(false) 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")
})
+1
View File
@@ -2,6 +2,7 @@ import { z } from "zod"
import { parse, type TemplateVariable } from "./parser" import { parse, type TemplateVariable } from "./parser"
import { renderDir, type RenderOptions } from "./render" import { renderDir, type RenderOptions } from "./render"
export { TargetExistsError } from "./render"
export type { RenderOptions, ReverseMapFile, ReverseMapManifest, ReverseMapStoredTemplate, ReverseMapToken } from "./render" export type { RenderOptions, ReverseMapFile, ReverseMapManifest, ReverseMapStoredTemplate, ReverseMapToken } from "./render"
export { reverseDir, type ReverseOptions, type ReverseResult, type ReverseWarning } from "./reverse" export { reverseDir, type ReverseOptions, type ReverseResult, type ReverseWarning } from "./reverse"
+6 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@gregorlohaus/tdir", "name": "@gregorlohaus/tdir",
"version": "0.2.0", "version": "0.3.0",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"bin": { "bin": {
"tdir": "./dist/cli.js" "tdir": "dist/cli.js"
}, },
"exports": { "exports": {
".": { ".": {
@@ -25,5 +25,9 @@
}, },
"peerDependencies": { "peerDependencies": {
"zod": "^4" "zod": "^4"
},
"repository": {
"type": "git",
"url": "https://gitea.gregorlohaus.com/gregor/tdir"
} }
} }
+32 -5
View File
@@ -58,12 +58,24 @@ export type ReverseMapManifest = {
export type RenderOptions = { export type RenderOptions = {
reverseMap?: boolean | string 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 = { type RenderState = {
sourceRoot: string sourceRoot: string
destRoot: string destRoot: string
manifest?: ReverseMapManifest manifest?: ReverseMapManifest
rename: Record<string, string>
} }
function evalCondition(expr: string | undefined, context: Record<string, unknown>): boolean { function evalCondition(expr: string | undefined, context: Record<string, unknown>): boolean {
@@ -169,6 +181,7 @@ function getReverseMapPath(destRoot: string, reverseMap: true | string): string
function getOutputName( function getOutputName(
entry: string, entry: string,
context: Record<string, unknown>, context: Record<string, unknown>,
rename: Record<string, string>,
state?: RenderState, state?: RenderState,
file?: ReverseMapFile, file?: ReverseMapFile,
): string | null { ): string | null {
@@ -188,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) ?? "") const result = String(resolveContext(context, path) ?? "")
addReverseMapToken(state, file, { addReverseMapToken(state, file, {
kind: "path", kind: "path",
@@ -200,6 +213,15 @@ function getOutputName(
}) })
return result 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 { function isUtf8Text(buffer: Buffer): boolean {
@@ -450,10 +472,14 @@ function renderDir(
sourceRoot, sourceRoot,
destRoot, destRoot,
manifest: options.reverseMap ? createReverseMapManifest() : undefined, manifest: options.reverseMap ? createReverseMapManifest() : undefined,
rename: options.rename ?? {},
} }
const mapPath = options.reverseMap ? getReverseMapPath(destRoot, options.reverseMap) : undefined const mapPath = options.reverseMap ? getReverseMapPath(destRoot, options.reverseMap) : undefined
assertSafeRenderTarget(srcDir, destDir) 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 }) rmSync(destDir, { recursive: true, force: true })
renderDirInner(srcDir, destRoot, context, state) renderDirInner(srcDir, destRoot, context, state)
if (state.manifest && mapPath) { if (state.manifest && mapPath) {
@@ -466,18 +492,19 @@ function validateOutputPaths(
srcDir: string, srcDir: string,
destDir: string, destDir: string,
context: Record<string, unknown>, context: Record<string, unknown>,
rename: Record<string, string>,
) { ) {
const entries = readdirSync(srcDir).sort() const entries = readdirSync(srcDir).sort()
for (const entry of entries) { for (const entry of entries) {
const srcPath = resolvePath(srcDir, entry) const srcPath = resolvePath(srcDir, entry)
const stat = statSync(srcPath) const stat = statSync(srcPath)
const outputName = getOutputName(entry, context) const outputName = getOutputName(entry, context, rename)
if (outputName === null) continue if (outputName === null) continue
const destPath = resolveOutputPath(destDir, outputName) const destPath = resolveOutputPath(destDir, outputName)
if (stat.isDirectory()) { if (stat.isDirectory()) {
validateOutputPaths(srcPath, destPath, context) validateOutputPaths(srcPath, destPath, context, rename)
} }
} }
} }
@@ -533,7 +560,7 @@ function renderDirInner(
templatePath, templatePath,
tokens: [], tokens: [],
} }
const outputName = getOutputName(entry, context, state, tempFile) const outputName = getOutputName(entry, context, state.rename, state, tempFile)
if (outputName === null) { if (outputName === null) {
storeSkippedTemplate(srcPath, state) storeSkippedTemplate(srcPath, state)
continue continue