Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a512cdbc3 |
15
README.md
15
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
|
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
21
cli.ts
@@ -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}`)
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
168
reverse.ts
168
reverse.ts
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user