diff --git a/README.md b/README.md index c82ad32..4be4ac4 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,21 @@ tdir reverse ./output ./templates --map meta/reverse-map.json bunx @gregorlohaus/tdir reverse ./output ./templates --map meta/reverse-map.json ``` +New files created in the rendered directory are ignored by default. Include them explicitly with one or more glob patterns: + +```sh +tdir reverse ./output ./templates --include "components/**" +tdir reverse ./output ./templates --include "components/**/*.ts" --include "pages/*.html" +``` + +Programmatically, pass the same globs to `reverseDir`: + +```ts +reverseDir("./output", "./templates", { + include: ["components/**/*.ts", "pages/*.html"] +}) +``` + The command writes files at their original template paths, restores recorded `<@var(...)>` tokens, wraps edited inline conditional output back in the original conditional block, and restores template files that were skipped by path conditionals. ## Unmatched directives diff --git a/cli.ts b/cli.ts index aa04767..9fdc735 100644 --- a/cli.ts +++ b/cli.ts @@ -5,7 +5,7 @@ function printHelp() { console.log(`tdir Usage: - tdir reverse [--map ] + tdir reverse [--map ] [--include ...] Commands: reverse Rebuild template files from a rendered directory and reverse map @@ -13,18 +13,20 @@ Commands: Options: --map Reverse map path. Defaults to /.tdir-map.json. Relative paths are resolved from . + --include Include new rendered files matching a glob. Can be repeated. --help Show this help message. `) } function parseReverseArgs(args: string[]) { const positional: string[] = [] + const include: string[] = [] let mapPath: string | undefined for (let i = 0; i < args.length; i++) { const arg = args[i]! if (arg === "--help" || arg === "-h") { - return { help: true, positional, mapPath } + return { help: true, positional, mapPath, include } } if (arg === "--map") { const value = args[++i] @@ -32,10 +34,16 @@ function parseReverseArgs(args: string[]) { mapPath = value continue } + if (arg === "--include") { + const value = args[++i] + if (!value) throw new Error("Missing value for --include") + include.push(value) + continue + } positional.push(arg) } - return { help: false, positional, mapPath } + return { help: false, positional, mapPath, include } } function main(argv: string[]) { @@ -58,10 +66,13 @@ function main(argv: string[]) { const [renderedDir, templateDir] = parsed.positional if (!renderedDir || !templateDir || parsed.positional.length > 2) { - throw new Error("Usage: tdir reverse [--map ]") + throw new Error("Usage: tdir reverse [--map ] [--include ...]") } - const result = reverseDir(renderedDir, templateDir, { mapPath: parsed.mapPath }) + const result = reverseDir(renderedDir, templateDir, { + mapPath: parsed.mapPath, + include: parsed.include, + }) console.log(`Wrote ${result.filesWritten} file${result.filesWritten === 1 ? "" : "s"}`) for (const warning of result.warnings) { console.warn(`Warning: ${warning.outputPath}: ${warning.message}`) diff --git a/index.test.ts b/index.test.ts index 5123f47..c9c334c 100644 --- a/index.test.ts +++ b/index.test.ts @@ -207,6 +207,44 @@ test("reverseDir supports custom map paths", () => { expect(existsSync(join(templateOut, "<@if(context.web.create)><@var(context.web.dir)>", "var_in_path_example.html"))).toBe(true) }) +test("reverseDir only includes new rendered files matching include globs", () => { + 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() + }) + })) + const renderedOut = join(tmp, "rendered") + const templateOut = join(tmp, "template") + const ignoredOut = join(tmp, "ignored-template") + + render(renderedOut,{ + web: { + create: true, + dir: "components" + }, + header: { + render: false, + text: "test" + } + }, { reverseMap: true }) + writeFileSync(join(renderedOut, "components", "new.ts"), "export const value = 1\n") + writeFileSync(join(renderedOut, "components", "debug.tmp"), "debug\n") + + reverseDir(renderedOut, ignoredOut) + expect(existsSync(join(ignoredOut, "<@if(context.web.create)><@var(context.web.dir)>", "new.ts"))).toBe(false) + + const result = reverseDir(renderedOut, templateOut, { include: ["components/**/*.ts"] }) + expect(result.filesWritten).toBe(2) + expect(existsSync(join(templateOut, "<@if(context.web.create)><@var(context.web.dir)>", "new.ts"))).toBe(true) + expect(existsSync(join(templateOut, "<@if(context.web.create)><@var(context.web.dir)>", "debug.tmp"))).toBe(false) +}) + test("wrong schema throws error",() => { const createRenderer = initRenderer("./testdata/if_example") expect(() => createRenderer(z.object({ diff --git a/package.json b/package.json index 007d497..20d8c33 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@gregorlohaus/tdir", - "version": "0.1.4", + "version": "0.1.5", "license": "MIT", "type": "module", "main": "./dist/index.js", diff --git a/reverse.ts b/reverse.ts index c0bb00e..2599eed 100644 --- a/reverse.ts +++ b/reverse.ts @@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readFileSync, + readdirSync, statSync, writeFileSync, } from "node:fs" @@ -12,6 +13,7 @@ import type { ReverseMapFile, ReverseMapManifest, ReverseMapStoredTemplate, Reve export type ReverseOptions = { mapPath?: string + include?: string | string[] } export type ReverseWarning = { @@ -57,6 +59,51 @@ function readManifest(mapPath: string): ReverseMapManifest { return manifest } +function normalizePath(path: string): string { + return path.split("\\").join("/") +} + +function escapeRegExp(text: string): string { + return text.replace(/[|\\{}()[\]^$+?.]/g, "\\$&") +} + +function globToRegExp(glob: string): RegExp { + let source = "^" + const pattern = normalizePath(glob) + + for (let i = 0; i < pattern.length; i++) { + const char = pattern[i]! + const next = pattern[i + 1] + if (char === "*" && next === "*") { + if (pattern[i + 2] === "/") { + source += "(?:.*/)?" + i += 2 + } else { + source += ".*" + i += 1 + } + } else if (char === "*") { + source += "[^/]*" + } else if (char === "?") { + source += "[^/]" + } else { + source += escapeRegExp(char) + } + } + + return new RegExp(`${source}$`) +} + +function getIncludeMatchers(include: ReverseOptions["include"]): RegExp[] { + if (!include) return [] + return (Array.isArray(include) ? include : [include]).map(globToRegExp) +} + +function matchesAny(path: string, matchers: RegExp[]): boolean { + const normalized = normalizePath(path) + return matchers.some(matcher => matcher.test(normalized)) +} + function replaceAtRange(content: string, token: ReverseMapToken): string | null { if (!token.range) return null const { start, end } = token.range @@ -198,6 +245,119 @@ function writeSkippedTemplate( return 1 } +function dirnamePath(path: string): string { + const normalized = normalizePath(path) + const index = normalized.lastIndexOf("/") + return index === -1 ? "" : normalized.slice(0, index) +} + +function basenamePath(path: string): string { + const normalized = normalizePath(path) + const index = normalized.lastIndexOf("/") + return index === -1 ? normalized : normalized.slice(index + 1) +} + +function joinPath(...parts: string[]): string { + return parts + .filter(part => part !== "") + .join("/") +} + +function buildDirectoryMap(manifest: ReverseMapManifest): Map { + const mappings = new Map([["", ""]]) + + function addMapping(outputDir: string, templateDir: string) { + const outputParts = normalizePath(outputDir).split("/").filter(Boolean) + const templateParts = normalizePath(templateDir).split("/").filter(Boolean) + for (let i = 1; i <= outputParts.length; i++) { + if (i <= templateParts.length) { + mappings.set(outputParts.slice(0, i).join("/"), templateParts.slice(0, i).join("/")) + } + } + mappings.set(normalizePath(outputDir), normalizePath(templateDir)) + } + + for (const file of manifest.files) { + const output = normalizePath(file.outputPath) + const template = normalizePath(file.templatePath) + addMapping(dirnamePath(output), dirnamePath(template)) + if (file.tokens.some(token => token.kind === "path")) addMapping(output, template) + } + + for (const skipped of manifest.skipped ?? []) { + if (skipped.kind === "directory") continue + addMapping(dirnamePath(skipped.templatePath), dirnamePath(skipped.templatePath)) + } + + return mappings +} + +function inferTemplatePath(outputPath: string, directoryMap: Map): string { + const normalized = normalizePath(outputPath) + const outputDir = dirnamePath(normalized) + let bestOutputDir = "" + let bestTemplateDir = "" + + for (const [mappedOutputDir, mappedTemplateDir] of directoryMap) { + if ( + mappedOutputDir.length >= bestOutputDir.length + && (outputDir === mappedOutputDir || outputDir.startsWith(`${mappedOutputDir}/`)) + ) { + bestOutputDir = mappedOutputDir + bestTemplateDir = mappedTemplateDir + } + } + + const suffix = bestOutputDir === "" + ? outputDir + : outputDir.slice(bestOutputDir.length).replace(/^\//, "") + return joinPath(bestTemplateDir, suffix, basenamePath(normalized)) +} + +function walkFiles(root: string, current = root): string[] { + const files: string[] = [] + for (const entry of readdirSync(current).sort()) { + const path = resolvePath(current, entry) + const stat = statSync(path) + if (stat.isDirectory()) { + files.push(...walkFiles(root, path)) + } else if (stat.isFile()) { + files.push(normalizePath(relative(root, path))) + } + } + return files +} + +function copyIncludedRenderedFiles( + renderedRoot: string, + templateRoot: string, + mapPath: string, + manifest: ReverseMapManifest, + include: ReverseOptions["include"], +): number { + const matchers = getIncludeMatchers(include) + if (matchers.length === 0) return 0 + + const mappedOutputPaths = new Set(manifest.files.map(file => normalizePath(file.outputPath))) + const mapRelativePath = normalizePath(relative(renderedRoot, mapPath)) + const directoryMap = buildDirectoryMap(manifest) + let filesWritten = 0 + + for (const outputPath of walkFiles(renderedRoot)) { + if (outputPath === mapRelativePath) continue + if (mappedOutputPaths.has(outputPath)) continue + if (!matchesAny(outputPath, matchers)) continue + + const renderedPath = resolveInside(renderedRoot, outputPath) + const templatePath = resolveInside(templateRoot, inferTemplatePath(outputPath, directoryMap)) + mkdirSync(dirname(templatePath), { recursive: true }) + copyFileSync(renderedPath, templatePath) + filesWritten += 1 + } + + return filesWritten +} + export function reverseDir( renderedDir: string, templateDir: string, @@ -252,5 +412,13 @@ export function reverseDir( filesWritten += writeSkippedTemplate(templateRoot, skipped) } + filesWritten += copyIncludedRenderedFiles( + renderedRoot, + templateRoot, + mapPath, + manifest, + options.include, + ) + return { filesWritten, warnings } }