reverse accepts new file paths via globs
All checks were successful
Publish npm package / publish (push) Successful in 22s

This commit is contained in:
Gregor Lohaus
2026-05-24 14:46:07 +02:00
parent 2971c87618
commit 0a512cdbc3
5 changed files with 238 additions and 6 deletions

View File

@@ -208,6 +208,21 @@ tdir reverse ./output ./templates --map meta/reverse-map.json
bunx @gregorlohaus/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. 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 ## Unmatched directives

21
cli.ts
View File

@@ -5,7 +5,7 @@ function printHelp() {
console.log(`tdir console.log(`tdir
Usage: Usage:
tdir reverse <rendered-dir> <template-dir> [--map <path>] tdir reverse <rendered-dir> <template-dir> [--map <path>] [--include <glob>...]
Commands: Commands:
reverse Rebuild template files from a rendered directory and reverse map reverse Rebuild template files from a rendered directory and reverse map
@@ -13,18 +13,20 @@ Commands:
Options: Options:
--map Reverse map path. Defaults to <rendered-dir>/.tdir-map.json. --map Reverse map path. Defaults to <rendered-dir>/.tdir-map.json.
Relative paths are resolved from <rendered-dir>. Relative paths are resolved from <rendered-dir>.
--include Include new rendered files matching a glob. Can be repeated.
--help Show this help message. --help Show this help message.
`) `)
} }
function parseReverseArgs(args: string[]) { function parseReverseArgs(args: string[]) {
const positional: string[] = [] const positional: string[] = []
const include: string[] = []
let mapPath: string | undefined let mapPath: string | undefined
for (let i = 0; i < args.length; i++) { for (let i = 0; i < args.length; i++) {
const arg = args[i]! const arg = args[i]!
if (arg === "--help" || arg === "-h") { if (arg === "--help" || arg === "-h") {
return { help: true, positional, mapPath } return { help: true, positional, mapPath, include }
} }
if (arg === "--map") { if (arg === "--map") {
const value = args[++i] const value = args[++i]
@@ -32,10 +34,16 @@ function parseReverseArgs(args: string[]) {
mapPath = value mapPath = value
continue continue
} }
if (arg === "--include") {
const value = args[++i]
if (!value) throw new Error("Missing value for --include")
include.push(value)
continue
}
positional.push(arg) positional.push(arg)
} }
return { help: false, positional, mapPath } return { help: false, positional, mapPath, include }
} }
function main(argv: string[]) { function main(argv: string[]) {
@@ -58,10 +66,13 @@ function main(argv: string[]) {
const [renderedDir, templateDir] = parsed.positional const [renderedDir, templateDir] = parsed.positional
if (!renderedDir || !templateDir || parsed.positional.length > 2) { if (!renderedDir || !templateDir || parsed.positional.length > 2) {
throw new Error("Usage: tdir reverse <rendered-dir> <template-dir> [--map <path>]") throw new Error("Usage: tdir reverse <rendered-dir> <template-dir> [--map <path>] [--include <glob>...]")
} }
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"}`) console.log(`Wrote ${result.filesWritten} file${result.filesWritten === 1 ? "" : "s"}`)
for (const warning of result.warnings) { for (const warning of result.warnings) {
console.warn(`Warning: ${warning.outputPath}: ${warning.message}`) console.warn(`Warning: ${warning.outputPath}: ${warning.message}`)

View File

@@ -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) 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",() => { test("wrong schema throws error",() => {
const createRenderer = initRenderer("./testdata/if_example") const createRenderer = initRenderer("./testdata/if_example")
expect(() => createRenderer(z.object({ expect(() => createRenderer(z.object({

View File

@@ -1,6 +1,6 @@
{ {
"name": "@gregorlohaus/tdir", "name": "@gregorlohaus/tdir",
"version": "0.1.4", "version": "0.1.5",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",

View File

@@ -3,6 +3,7 @@ import {
existsSync, existsSync,
mkdirSync, mkdirSync,
readFileSync, readFileSync,
readdirSync,
statSync, statSync,
writeFileSync, writeFileSync,
} from "node:fs" } from "node:fs"
@@ -12,6 +13,7 @@ import type { ReverseMapFile, ReverseMapManifest, ReverseMapStoredTemplate, Reve
export type ReverseOptions = { export type ReverseOptions = {
mapPath?: string mapPath?: string
include?: string | string[]
} }
export type ReverseWarning = { export type ReverseWarning = {
@@ -57,6 +59,51 @@ function readManifest(mapPath: string): ReverseMapManifest {
return manifest 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 { function replaceAtRange(content: string, token: ReverseMapToken): string | null {
if (!token.range) return null if (!token.range) return null
const { start, end } = token.range const { start, end } = token.range
@@ -198,6 +245,119 @@ function writeSkippedTemplate(
return 1 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<string, string> {
const mappings = new Map<string, string>([["", ""]])
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, string>): 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( export function reverseDir(
renderedDir: string, renderedDir: string,
templateDir: string, templateDir: string,
@@ -252,5 +412,13 @@ export function reverseDir(
filesWritten += writeSkippedTemplate(templateRoot, skipped) filesWritten += writeSkippedTemplate(templateRoot, skipped)
} }
filesWritten += copyIncludedRenderedFiles(
renderedRoot,
templateRoot,
mapPath,
manifest,
options.include,
)
return { filesWritten, warnings } return { filesWritten, warnings }
} }