Compare commits
4 Commits
v0.1.5
...
0fab9c8d38
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fab9c8d38 | ||
|
|
e16fc8b482 | ||
|
|
7d01b2f7c9 | ||
|
|
0412cea241 |
10
README.md
10
README.md
@@ -60,6 +60,7 @@ render("./output", {
|
||||
|---|---|
|
||||
| `<@if(context.x)>` | Conditional block — boolean check (must end with `<@endif>`) |
|
||||
| `<@if(eq(context.x,"value"))>` | Conditional block — string equality check |
|
||||
| `<@if(neq(context.x,"value"))>` | Conditional block — string inequality check |
|
||||
| `<@elseif(context.y)>` | Else-if branch (same forms as `@if`) |
|
||||
| `<@else>` | Else branch |
|
||||
| `<@endif>` | End conditional block |
|
||||
@@ -72,6 +73,7 @@ render("./output", {
|
||||
|---|---|
|
||||
| `<@if(context.x)>dirname` | Conditionally include directory/file (boolean check) |
|
||||
| `<@if(eq(context.x,"value"))>dirname` | Conditionally include by string equality |
|
||||
| `<@if(neq(context.x,"value"))>dirname` | Conditionally include by string inequality |
|
||||
| `<@var(context.x)>` | Dynamic directory/file name |
|
||||
|
||||
These can be combined: `<@if(context.web.create)><@var(context.web.dir)>` creates a directory named by `context.web.dir` only if `context.web.create` is true.
|
||||
@@ -225,6 +227,14 @@ reverseDir("./output", "./templates", {
|
||||
|
||||
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 template output directory may be inside the rendered directory, for example:
|
||||
|
||||
```sh
|
||||
tdir reverse ./ ./reversed --include "components/**"
|
||||
```
|
||||
|
||||
When the template output directory is inside the rendered directory, reverse snapshots included files before writing and excludes the output directory from include glob matching.
|
||||
|
||||
## Unmatched directives
|
||||
|
||||
A `<@if>` without a matching `<@endif>` throws when the renderer is initialized:
|
||||
|
||||
@@ -229,22 +229,38 @@ test("reverseDir only includes new rendered files matching include globs", () =>
|
||||
dir: "components"
|
||||
},
|
||||
header: {
|
||||
render: false,
|
||||
render: true,
|
||||
text: "test"
|
||||
}
|
||||
}, { reverseMap: true })
|
||||
writeFileSync(join(renderedOut, "components", "new.ts"), "export const value = 1\n")
|
||||
writeFileSync(join(renderedOut, "components", "title.ts"), "export const title = 'test'\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(result.filesWritten).toBe(3)
|
||||
expect(existsSync(join(templateOut, "<@if(context.web.create)><@var(context.web.dir)>", "new.ts"))).toBe(true)
|
||||
expect(readFileSync(join(templateOut, "<@if(context.web.create)><@var(context.web.dir)>", "title.ts"), "utf-8")).toContain("<@var(context.header.text)>")
|
||||
expect(existsSync(join(templateOut, "<@if(context.web.create)><@var(context.web.dir)>", "debug.tmp"))).toBe(false)
|
||||
})
|
||||
|
||||
test("reverseDir can write templates inside rendered output without including its own writes", () => {
|
||||
const createRenderer = initRenderer("./testdata/if_example")
|
||||
const render = createRenderer(ifExampleSchema)
|
||||
const renderedOut = join(tmp, "rendered")
|
||||
render(renderedOut, { web: true, header: { render: true, text: "My Title" } }, { reverseMap: true })
|
||||
writeFileSync(join(renderedOut, "new.html"), "<main>new</main>\n")
|
||||
|
||||
const result = reverseDir(renderedOut, join(renderedOut, "reversed"), { include: ["**/*"] })
|
||||
expect(result.warnings).toEqual([])
|
||||
expect(existsSync(join(renderedOut, "reversed", "<@if(context.web)>web", "if_example.html"))).toBe(true)
|
||||
expect(existsSync(join(renderedOut, "reversed", "new.html"))).toBe(true)
|
||||
expect(existsSync(join(renderedOut, "reversed", "reversed", "new.html"))).toBe(false)
|
||||
})
|
||||
|
||||
test("wrong schema throws error",() => {
|
||||
const createRenderer = initRenderer("./testdata/if_example")
|
||||
expect(() => createRenderer(z.object({
|
||||
@@ -552,3 +568,25 @@ test("if eq in path", () => {
|
||||
render(tmp,{test:"foo"})
|
||||
expect(existsSync(join(tmp,"test"))).toBe(false)
|
||||
})
|
||||
|
||||
test("if neq in file", () => {
|
||||
const createRenderer = initRenderer("./testdata/neq_in_file")
|
||||
expect(() => createRenderer(z.object({test: z.boolean()}))).toThrow(SchemaMismatchError)
|
||||
const render = createRenderer(z.object({test: z.string()}))
|
||||
|
||||
render(tmp,{test:"foo"})
|
||||
expect(readFileSync(join(tmp,"test.txt"), "utf-8")).toContain("not-test")
|
||||
|
||||
render(tmp,{test:"test"})
|
||||
expect(readFileSync(join(tmp,"test.txt"), "utf-8")).toContain("test")
|
||||
expect(readFileSync(join(tmp,"test.txt"), "utf-8")).not.toContain("not-test")
|
||||
})
|
||||
|
||||
test("if neq in path", () => {
|
||||
const createRenderer = initRenderer("./testdata/neq_in_path")
|
||||
const render = createRenderer(z.object({test: z.string()}))
|
||||
render(tmp,{test:"foo"})
|
||||
expect(existsSync(join(tmp,"not-test"))).toBe(true)
|
||||
render(tmp,{test:"test"})
|
||||
expect(existsSync(join(tmp,"not-test"))).toBe(false)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@gregorlohaus/tdir",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.8",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -10,14 +10,14 @@ export type TemplateVariable = {
|
||||
const IF_RE = /<@(?:if|elseif)\((.+?)\)>/g
|
||||
const VAR_RE = /<@var\(context\.(.+?)(?::(\w+))?\)>/g
|
||||
const DIRECTIVE_RE = /<@(if|elseif|else|endif)(?:\((.+?)\))?>/g
|
||||
const EQ_RE = /^eq\(context\.(.+?),\s*"(.*)"\)$/
|
||||
const STRING_COMPARE_RE = /^(?:eq|neq)\(context\.(.+?),\s*"(.*)"\)$/
|
||||
const PATH_RE = /^context\.(.+)$/
|
||||
|
||||
function extractCondition(expr: string | undefined, vars: TemplateVariable[]) {
|
||||
if (!expr) throw new Error("Missing condition expression")
|
||||
const eqMatch = expr.match(EQ_RE)
|
||||
if (eqMatch) {
|
||||
vars.push({ path: eqMatch[1]!, type: "string" })
|
||||
const stringCompareMatch = expr.match(STRING_COMPARE_RE)
|
||||
if (stringCompareMatch) {
|
||||
vars.push({ path: stringCompareMatch[1]!, type: "string" })
|
||||
return
|
||||
}
|
||||
const pathMatch = expr.match(PATH_RE)
|
||||
|
||||
26
render.ts
26
render.ts
@@ -14,7 +14,7 @@ import { TextDecoder } from "node:util"
|
||||
const IF_PATH_RE = /^<@if\((.+?)\)>(.*)$/
|
||||
const VAR_RE = /<@var\(context\.(.+?)(?::(\w+))?\)>/g
|
||||
const DIRECTIVE_RE = /<@(if|elseif|else|endif)(?:\((.+?)\))?>/g
|
||||
const EQ_RE = /^eq\(context\.(.+?),\s*"(.*)"\)$/
|
||||
const STRING_COMPARE_RE = /^(eq|neq)\(context\.(.+?),\s*"(.*)"\)$/
|
||||
const PATH_RE = /^context\.(.+)$/
|
||||
|
||||
export type ReverseMapToken = {
|
||||
@@ -68,9 +68,10 @@ type RenderState = {
|
||||
|
||||
function evalCondition(expr: string | undefined, context: Record<string, unknown>): boolean {
|
||||
if (!expr) throw new Error("Missing condition expression")
|
||||
const eqMatch = expr.match(EQ_RE)
|
||||
if (eqMatch) {
|
||||
return resolveContext(context, eqMatch[1]!) === eqMatch[2]
|
||||
const stringCompareMatch = expr.match(STRING_COMPARE_RE)
|
||||
if (stringCompareMatch) {
|
||||
const result = resolveContext(context, stringCompareMatch[2]!) === stringCompareMatch[3]
|
||||
return stringCompareMatch[1] === "eq" ? result : !result
|
||||
}
|
||||
const pathMatch = expr.match(PATH_RE)
|
||||
if (pathMatch) {
|
||||
@@ -139,6 +140,17 @@ function addReverseMapToken(
|
||||
state.manifest.tokens[token.result] = tokens
|
||||
}
|
||||
|
||||
function addFlatToken(
|
||||
state: RenderState | undefined,
|
||||
result: string,
|
||||
token: string,
|
||||
) {
|
||||
if (!state?.manifest) return
|
||||
const tokens = state.manifest.tokens[result] ?? []
|
||||
if (!tokens.includes(token)) tokens.push(token)
|
||||
state.manifest.tokens[result] = tokens
|
||||
}
|
||||
|
||||
function getReverseMapPath(destRoot: string, reverseMap: true | string): string {
|
||||
if (reverseMap === true) return resolveOutputPath(destRoot, ".tdir-map.json")
|
||||
return resolveOutputPath(destRoot, reverseMap)
|
||||
@@ -384,6 +396,12 @@ function renderContentWithMap(
|
||||
state?: RenderState,
|
||||
file?: ReverseMapFile,
|
||||
): string {
|
||||
for (const match of content.matchAll(VAR_RE)) {
|
||||
const token = match[0]
|
||||
const path = match[1]!
|
||||
addFlatToken(state, String(resolveContext(context, path) ?? ""), token)
|
||||
}
|
||||
|
||||
const processedResult = processIfBlocksWithMap(content, context)
|
||||
const processed = processedResult.content
|
||||
for (const token of processedResult.conditionalTokens) {
|
||||
|
||||
76
reverse.ts
76
reverse.ts
@@ -245,6 +245,19 @@ function writeSkippedTemplate(
|
||||
return 1
|
||||
}
|
||||
|
||||
function replaceFlatTokens(content: string, manifest: ReverseMapManifest): string {
|
||||
const entries = Object.entries(manifest.tokens)
|
||||
.filter(([, tokens]) => tokens.length > 0)
|
||||
.sort(([a], [b]) => b.length - a.length)
|
||||
|
||||
let result = content
|
||||
for (const [rendered, tokens] of entries) {
|
||||
if (rendered === "") continue
|
||||
result = result.split(rendered).join(tokens[0]!)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function dirnamePath(path: string): string {
|
||||
const normalized = normalizePath(path)
|
||||
const index = normalized.lastIndexOf("/")
|
||||
@@ -314,50 +327,71 @@ function inferTemplatePath(outputPath: string, directoryMap: Map<string, string>
|
||||
return joinPath(bestTemplateDir, suffix, basenamePath(normalized))
|
||||
}
|
||||
|
||||
function walkFiles(root: string, current = root): string[] {
|
||||
function walkFiles(root: string, excludedRoots: string[] = []): string[] {
|
||||
const files: string[] = []
|
||||
const pending = [root]
|
||||
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop()!
|
||||
for (const entry of readdirSync(current).sort()) {
|
||||
const path = resolvePath(current, entry)
|
||||
if (excludedRoots.some(excluded => isInsidePath(excluded, path))) continue
|
||||
const stat = statSync(path)
|
||||
if (stat.isDirectory()) {
|
||||
files.push(...walkFiles(root, path))
|
||||
pending.push(path)
|
||||
} else if (stat.isFile()) {
|
||||
files.push(normalizePath(relative(root, path)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
function copyIncludedRenderedFiles(
|
||||
renderedRoot: string,
|
||||
templateRoot: string,
|
||||
mapPath: string,
|
||||
includedOutputPaths: string[],
|
||||
directoryMap: Map<string, 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
|
||||
|
||||
for (const outputPath of includedOutputPaths) {
|
||||
const renderedPath = resolveInside(renderedRoot, outputPath)
|
||||
const templatePath = resolveInside(templateRoot, inferTemplatePath(outputPath, directoryMap))
|
||||
mkdirSync(dirname(templatePath), { recursive: true })
|
||||
const content = readFileSync(renderedPath)
|
||||
if (isUtf8Text(content)) {
|
||||
writeFileSync(templatePath, replaceFlatTokens(content.toString("utf-8"), manifest))
|
||||
} else {
|
||||
copyFileSync(renderedPath, templatePath)
|
||||
}
|
||||
filesWritten += 1
|
||||
}
|
||||
|
||||
return filesWritten
|
||||
}
|
||||
|
||||
function getIncludedRenderedFiles(
|
||||
renderedRoot: string,
|
||||
templateRoot: string,
|
||||
mapPath: string,
|
||||
manifest: ReverseMapManifest,
|
||||
include: ReverseOptions["include"],
|
||||
): string[] {
|
||||
const matchers = getIncludeMatchers(include)
|
||||
if (matchers.length === 0) return []
|
||||
|
||||
const mappedOutputPaths = new Set(manifest.files.map(file => normalizePath(file.outputPath)))
|
||||
const mapRelativePath = normalizePath(relative(renderedRoot, mapPath))
|
||||
return walkFiles(renderedRoot, [templateRoot]).filter(outputPath => {
|
||||
return outputPath !== mapRelativePath
|
||||
&& !mappedOutputPaths.has(outputPath)
|
||||
&& matchesAny(outputPath, matchers)
|
||||
})
|
||||
}
|
||||
|
||||
export function reverseDir(
|
||||
renderedDir: string,
|
||||
templateDir: string,
|
||||
@@ -369,6 +403,14 @@ export function reverseDir(
|
||||
? resolvePath(renderedRoot, options.mapPath)
|
||||
: resolvePath(renderedRoot, ".tdir-map.json")
|
||||
const manifest = readManifest(mapPath)
|
||||
const directoryMap = buildDirectoryMap(manifest)
|
||||
const includedOutputPaths = getIncludedRenderedFiles(
|
||||
renderedRoot,
|
||||
templateRoot,
|
||||
mapPath,
|
||||
manifest,
|
||||
options.include,
|
||||
)
|
||||
const warnings: ReverseWarning[] = []
|
||||
let filesWritten = 0
|
||||
|
||||
@@ -415,9 +457,9 @@ export function reverseDir(
|
||||
filesWritten += copyIncludedRenderedFiles(
|
||||
renderedRoot,
|
||||
templateRoot,
|
||||
mapPath,
|
||||
includedOutputPaths,
|
||||
directoryMap,
|
||||
manifest,
|
||||
options.include,
|
||||
)
|
||||
|
||||
return { filesWritten, warnings }
|
||||
|
||||
1
testdata/neq_in_file/test.txt
vendored
Normal file
1
testdata/neq_in_file/test.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<@if(neq(context.test,"test"))>not-test<@else>test<@endif>
|
||||
1
testdata/neq_in_path/<@if(neq(context.test,"test"))>not-test
vendored
Normal file
1
testdata/neq_in_path/<@if(neq(context.test,"test"))>not-test
vendored
Normal file
@@ -0,0 +1 @@
|
||||
not test
|
||||
Reference in New Issue
Block a user