diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml index f09cd60..70cd0a2 100644 --- a/.gitea/workflows/publish.yml +++ b/.gitea/workflows/publish.yml @@ -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 }} diff --git a/README.md b/README.md index d615de7..a688ff8 100644 --- a/README.md +++ b/README.md @@ -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. +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: diff --git a/devenv.lock b/devenv.lock index 2245218..20cf7d3 100644 --- a/devenv.lock +++ b/devenv.lock @@ -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": { diff --git a/index.test.ts b/index.test.ts index c031f5d..a0b78d0 100644 --- a/index.test.ts +++ b/index.test.ts @@ -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" @@ -655,3 +655,72 @@ test("and or in path", () => { 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") +}) diff --git a/index.ts b/index.ts index cce5d76..b619455 100644 --- a/index.ts +++ b/index.ts @@ -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" diff --git a/package.json b/package.json index 1ef6421..ad5a5fc 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "@gregorlohaus/tdir", - "version": "0.2.1", + "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": { ".": { diff --git a/render.ts b/render.ts index 8fafe46..909c120 100644 --- a/render.ts +++ b/render.ts @@ -58,12 +58,24 @@ 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 +} + +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 } function evalCondition(expr: string | undefined, context: Record): boolean { @@ -169,6 +181,7 @@ function getReverseMapPath(destRoot: string, reverseMap: true | string): string function getOutputName( entry: string, context: Record, + rename: Record, state?: RenderState, file?: ReverseMapFile, ): 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) ?? "") addReverseMapToken(state, file, { kind: "path", @@ -200,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 { @@ -450,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) { @@ -466,18 +492,19 @@ function validateOutputPaths( srcDir: string, destDir: string, context: Record, + rename: Record, ) { 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) } } } @@ -533,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 diff --git a/testdata/multi_dir_one_var/<@if(context.web)>dir1/<@if(context.web)>dir1n/.gitkeep b/testdata/multi_dir_one_var/<@if(context.web)>dir1/<@if(context.web)>dir1n/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/testdata/multi_dir_one_var/<@if(context.web)>dir2/.gitkeep b/testdata/multi_dir_one_var/<@if(context.web)>dir2/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/testdata/nested_if_dir/<@if(context.web)>web/<@if(context.webnested)>nested/.gitkeep b/testdata/nested_if_dir/<@if(context.web)>web/<@if(context.webnested)>nested/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/testdata/nested_if_dir_named/<@if(context.webnested)>nested/<@if(context.web)>web/.gitkeep b/testdata/nested_if_dir_named/<@if(context.webnested)>nested/<@if(context.web)>web/.gitkeep new file mode 100644 index 0000000..e69de29