Compare commits
14 Commits
b04b719840
...
v0.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
452d872954 | ||
|
|
e900729c8f | ||
|
|
fe3489e217 | ||
|
|
9ed1324e06 | ||
|
|
d8536d83de | ||
|
|
0fab9c8d38 | ||
|
|
e16fc8b482 | ||
|
|
7d01b2f7c9 | ||
|
|
0412cea241 | ||
|
|
0a512cdbc3 | ||
|
|
2971c87618 | ||
|
|
af0c25e64b | ||
|
|
8ad2545310 | ||
|
|
4d25d07687 |
@@ -7,7 +7,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish:
|
publish:
|
||||||
runs-on: local
|
runs-on: x86
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|||||||
50
README.md
50
README.md
@@ -60,6 +60,9 @@ render("./output", {
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `<@if(context.x)>` | Conditional block — boolean check (must end with `<@endif>`) |
|
| `<@if(context.x)>` | Conditional block — boolean check (must end with `<@endif>`) |
|
||||||
| `<@if(eq(context.x,"value"))>` | Conditional block — string equality check |
|
| `<@if(eq(context.x,"value"))>` | Conditional block — string equality check |
|
||||||
|
| `<@if(neq(context.x,"value"))>` | Conditional block — string inequality check |
|
||||||
|
| `<@if(and(context.x,eq(context.y,"value")))>` | Conditional block — all child conditions must match |
|
||||||
|
| `<@if(or(context.x,eq(context.y,"value")))>` | Conditional block — any child condition may match |
|
||||||
| `<@elseif(context.y)>` | Else-if branch (same forms as `@if`) |
|
| `<@elseif(context.y)>` | Else-if branch (same forms as `@if`) |
|
||||||
| `<@else>` | Else branch |
|
| `<@else>` | Else branch |
|
||||||
| `<@endif>` | End conditional block |
|
| `<@endif>` | End conditional block |
|
||||||
@@ -72,6 +75,9 @@ render("./output", {
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `<@if(context.x)>dirname` | Conditionally include directory/file (boolean check) |
|
| `<@if(context.x)>dirname` | Conditionally include directory/file (boolean check) |
|
||||||
| `<@if(eq(context.x,"value"))>dirname` | Conditionally include by string equality |
|
| `<@if(eq(context.x,"value"))>dirname` | Conditionally include by string equality |
|
||||||
|
| `<@if(neq(context.x,"value"))>dirname` | Conditionally include by string inequality |
|
||||||
|
| `<@if(and(context.x,eq(context.y,"value")))>dirname` | Conditionally include by combined conditions |
|
||||||
|
| `<@if(or(context.x,eq(context.y,"value")))>dirname` | Conditionally include by alternate conditions |
|
||||||
| `<@var(context.x)>` | Dynamic directory/file name |
|
| `<@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.
|
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.
|
||||||
@@ -142,7 +148,7 @@ Pass a string to choose a custom JSON path inside the output directory:
|
|||||||
render("./output", context, { reverseMap: "meta/reverse-map.json" })
|
render("./output", context, { reverseMap: "meta/reverse-map.json" })
|
||||||
```
|
```
|
||||||
|
|
||||||
The map contains a flat lookup from rendered strings to template tokens plus per-file occurrences with path/range context:
|
The map contains a flat lookup from rendered strings to template tokens, per-file occurrences with path/range context, inline conditional blocks, and template files skipped by path conditionals:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -160,10 +166,27 @@ The map contains a flat lookup from rendered strings to template tokens plus per
|
|||||||
"outputPath": "web/index.html",
|
"outputPath": "web/index.html",
|
||||||
"templatePath": "<@if(context.web)>web/index.html",
|
"templatePath": "<@if(context.web)>web/index.html",
|
||||||
"range": { "start": 16, "end": 21 }
|
"range": { "start": 16, "end": 21 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "conditional",
|
||||||
|
"result": "<head><@var(context.header.title)></head>",
|
||||||
|
"token": "<@if(context.header.show)><head><@var(context.header.title)></head><@endif>",
|
||||||
|
"outputPath": "web/index.html",
|
||||||
|
"templatePath": "<@if(context.web)>web/index.html",
|
||||||
|
"range": { "start": 9, "end": 53 },
|
||||||
|
"activeRange": { "start": 27, "end": 71 }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"skipped": [
|
||||||
|
{
|
||||||
|
"kind": "file",
|
||||||
|
"templatePath": "<@if(context.docs)>docs/readme.md",
|
||||||
|
"encoding": "utf8",
|
||||||
|
"content": "# Docs"
|
||||||
|
}
|
||||||
|
],
|
||||||
"tokens": {
|
"tokens": {
|
||||||
"Hello": ["<@var(context.header.title)>"]
|
"Hello": ["<@var(context.header.title)>"]
|
||||||
}
|
}
|
||||||
@@ -191,7 +214,30 @@ 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
|
||||||
```
|
```
|
||||||
|
|
||||||
The command writes files at their original template paths and restores recorded `<@var(...)>` tokens in file contents and file paths. It does not infer conditional blocks that were removed during rendering; keep the original template structure when those blocks need to be preserved.
|
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 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
|
## 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}`)
|
||||||
|
|||||||
181
index.test.ts
181
index.test.ts
@@ -66,6 +66,17 @@ test("render can write a reverse map", () => {
|
|||||||
|
|
||||||
const file = manifest.files.find((entry: any) => entry.outputPath === join("web", "if_example.html"))
|
const file = manifest.files.find((entry: any) => entry.outputPath === join("web", "if_example.html"))
|
||||||
expect(file).toBeDefined()
|
expect(file).toBeDefined()
|
||||||
|
expect(file.tokens).toContainEqual({
|
||||||
|
kind: "conditional",
|
||||||
|
result: expect.stringContaining("<@var(context.header.text)>"),
|
||||||
|
token: expect.stringContaining("<@if(context.header.render)>"),
|
||||||
|
outputPath: join("web", "if_example.html"),
|
||||||
|
templatePath: join("<@if(context.web)>web", "if_example.html"),
|
||||||
|
range: expect.any(Object),
|
||||||
|
activeRange: expect.any(Object),
|
||||||
|
before: expect.any(String),
|
||||||
|
after: expect.any(String),
|
||||||
|
})
|
||||||
expect(file.tokens).toContainEqual({
|
expect(file.tokens).toContainEqual({
|
||||||
kind: "content",
|
kind: "content",
|
||||||
result: "My Title",
|
result: "My Title",
|
||||||
@@ -134,10 +145,37 @@ test("reverseDir rebuilds templates from rendered output and map", () => {
|
|||||||
expect(result.warnings).toEqual([])
|
expect(result.warnings).toEqual([])
|
||||||
|
|
||||||
const reversed = readFileSync(join(templateOut, "<@if(context.web)>web", "if_example.html"), "utf-8")
|
const reversed = readFileSync(join(templateOut, "<@if(context.web)>web", "if_example.html"), "utf-8")
|
||||||
|
expect(reversed).toContain("<@if(context.header.render)>")
|
||||||
|
expect(reversed).toContain("<@endif>")
|
||||||
expect(reversed).toContain("<@var(context.header.text)>")
|
expect(reversed).toContain("<@var(context.header.text)>")
|
||||||
expect(reversed).toContain("Edited rendered output")
|
expect(reversed).toContain("Edited rendered output")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("reverseDir restores files skipped by path conditionals", () => {
|
||||||
|
const createRenderer = initRenderer("./testdata/file_if")
|
||||||
|
const render = createRenderer(z.object({
|
||||||
|
web: z.boolean(),
|
||||||
|
file: z.boolean(),
|
||||||
|
text: z.string()
|
||||||
|
}))
|
||||||
|
const renderedOut = join(tmp, "rendered")
|
||||||
|
const templateOut = join(tmp, "template")
|
||||||
|
|
||||||
|
render(renderedOut,{
|
||||||
|
web: true,
|
||||||
|
file: false,
|
||||||
|
text: "test"
|
||||||
|
}, { reverseMap: true })
|
||||||
|
|
||||||
|
expect(existsSync(join(renderedOut, "web", "example.txt"))).toBe(false)
|
||||||
|
|
||||||
|
const result = reverseDir(renderedOut, templateOut)
|
||||||
|
expect(result.filesWritten).toBe(1)
|
||||||
|
const restoredPath = join(templateOut, "<@if(context.web)>web", "<@if(context.file)>example.txt")
|
||||||
|
expect(existsSync(restoredPath)).toBe(true)
|
||||||
|
expect(readFileSync(restoredPath, "utf-8")).toContain("<@var(context.text)>")
|
||||||
|
})
|
||||||
|
|
||||||
test("reverseDir supports custom map paths", () => {
|
test("reverseDir supports custom map paths", () => {
|
||||||
const createRenderer = initRenderer("./testdata/var_in_path")
|
const createRenderer = initRenderer("./testdata/var_in_path")
|
||||||
const render = createRenderer(z.object({
|
const render = createRenderer(z.object({
|
||||||
@@ -169,6 +207,60 @@ 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: 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(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",() => {
|
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({
|
||||||
@@ -359,7 +451,7 @@ test("file if",() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("no endif should throw",() => {
|
test("no endif should throw",() => {
|
||||||
expect(() => initRenderer("./testdata/no_end_if")).toThrow()
|
expect(() => initRenderer("./testdata/no_end_if")).toThrow("test.txt")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("path vars cannot write outside target",() => {
|
test("path vars cannot write outside target",() => {
|
||||||
@@ -476,3 +568,90 @@ test("if eq in path", () => {
|
|||||||
render(tmp,{test:"foo"})
|
render(tmp,{test:"foo"})
|
||||||
expect(existsSync(join(tmp,"test"))).toBe(false)
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("if neq multiline block", () => {
|
||||||
|
const createRenderer = initRenderer("./testdata/neq_multiline_block")
|
||||||
|
const render = createRenderer(z.object({
|
||||||
|
project: z.object({
|
||||||
|
frontend: z.string()
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
render(tmp,{project:{frontend:"react"}})
|
||||||
|
expect(readFileSync(join(tmp,"test.txt"), "utf-8")).toContain("bun dev")
|
||||||
|
|
||||||
|
render(tmp,{project:{frontend:"none"}})
|
||||||
|
expect(readFileSync(join(tmp,"test.txt"), "utf-8")).not.toContain("bun dev")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("and or in file", () => {
|
||||||
|
const createRenderer = initRenderer("./testdata/and_or_in_file")
|
||||||
|
expect(() => createRenderer(z.object({
|
||||||
|
enabled: z.boolean(),
|
||||||
|
fallback: z.boolean(),
|
||||||
|
kind: z.boolean()
|
||||||
|
}))).toThrow(SchemaMismatchError)
|
||||||
|
|
||||||
|
const render = createRenderer(z.object({
|
||||||
|
enabled: z.boolean(),
|
||||||
|
fallback: z.boolean(),
|
||||||
|
kind: z.string()
|
||||||
|
}))
|
||||||
|
|
||||||
|
render(tmp,{enabled:true, fallback:false, kind:"web"})
|
||||||
|
let content = readFileSync(join(tmp,"test.txt"), "utf-8")
|
||||||
|
expect(content).toContain("and")
|
||||||
|
expect(content).toContain("or")
|
||||||
|
expect(content).toContain("nested")
|
||||||
|
|
||||||
|
render(tmp,{enabled:false, fallback:true, kind:"web"})
|
||||||
|
content = readFileSync(join(tmp,"test.txt"), "utf-8")
|
||||||
|
expect(content).toContain("not-and")
|
||||||
|
expect(content).toContain("not-or")
|
||||||
|
expect(content).toContain("nested")
|
||||||
|
|
||||||
|
render(tmp,{enabled:false, fallback:true, kind:"docs"})
|
||||||
|
content = readFileSync(join(tmp,"test.txt"), "utf-8")
|
||||||
|
expect(content).toContain("not-and")
|
||||||
|
expect(content).toContain("or")
|
||||||
|
expect(content).toContain("nested")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("and or in path", () => {
|
||||||
|
const createRenderer = initRenderer("./testdata/and_or_in_path")
|
||||||
|
const render = createRenderer(z.object({
|
||||||
|
enabled: z.boolean(),
|
||||||
|
kind: z.string()
|
||||||
|
}))
|
||||||
|
|
||||||
|
render(tmp,{enabled:true, kind:"web"})
|
||||||
|
expect(existsSync(join(tmp,"match"))).toBe(true)
|
||||||
|
|
||||||
|
render(tmp,{enabled:true, kind:"docs"})
|
||||||
|
expect(existsSync(join(tmp,"match"))).toBe(true)
|
||||||
|
|
||||||
|
render(tmp,{enabled:false, kind:"docs"})
|
||||||
|
expect(existsSync(join(tmp,"match"))).toBe(false)
|
||||||
|
})
|
||||||
|
|||||||
2
index.ts
2
index.ts
@@ -2,7 +2,7 @@ import { z } from "zod"
|
|||||||
import { parse, type TemplateVariable } from "./parser"
|
import { parse, type TemplateVariable } from "./parser"
|
||||||
import { renderDir, type RenderOptions } from "./render"
|
import { renderDir, type RenderOptions } from "./render"
|
||||||
|
|
||||||
export type { RenderOptions, ReverseMapFile, ReverseMapManifest, ReverseMapToken } from "./render"
|
export type { RenderOptions, ReverseMapFile, ReverseMapManifest, ReverseMapStoredTemplate, ReverseMapToken } from "./render"
|
||||||
export { reverseDir, type ReverseOptions, type ReverseResult, type ReverseWarning } from "./reverse"
|
export { reverseDir, type ReverseOptions, type ReverseResult, type ReverseWarning } from "./reverse"
|
||||||
|
|
||||||
interface Stringable {
|
interface Stringable {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@gregorlohaus/tdir",
|
"name": "@gregorlohaus/tdir",
|
||||||
"version": "0.1.3",
|
"version": "0.2.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
@@ -25,5 +25,9 @@
|
|||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"zod": "^4"
|
"zod": "^4"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://gitea.gregorlohaus.com/gregor/tdir"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
68
parser.ts
68
parser.ts
@@ -1,23 +1,31 @@
|
|||||||
import { readdirSync, statSync, readFileSync } from "node:fs"
|
import { readdirSync, statSync, readFileSync } from "node:fs"
|
||||||
import { join } from "node:path"
|
import { join, relative } from "node:path"
|
||||||
import { TextDecoder } from "node:util"
|
import { TextDecoder } from "node:util"
|
||||||
|
import { getDirectiveTokens, splitArgs } from "./scanner"
|
||||||
|
|
||||||
export type TemplateVariable = {
|
export type TemplateVariable = {
|
||||||
path: string
|
path: string
|
||||||
type: string
|
type: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const IF_RE = /<@(?:if|elseif)\((.+?)\)>/g
|
|
||||||
const VAR_RE = /<@var\(context\.(.+?)(?::(\w+))?\)>/g
|
const VAR_RE = /<@var\(context\.(.+?)(?::(\w+))?\)>/g
|
||||||
const DIRECTIVE_RE = /<@(if|elseif|else|endif)(?:\((.+?)\))?>/g
|
const STRING_COMPARE_RE = /^(?:eq|neq)\(context\.(.+?),\s*"(.*)"\)$/
|
||||||
const EQ_RE = /^eq\(context\.(.+?),\s*"(.*)"\)$/
|
|
||||||
const PATH_RE = /^context\.(.+)$/
|
const PATH_RE = /^context\.(.+)$/
|
||||||
|
|
||||||
function extractCondition(expr: string | undefined, vars: TemplateVariable[]) {
|
function extractCondition(expr: string | undefined, vars: TemplateVariable[]) {
|
||||||
if (!expr) throw new Error("Missing condition expression")
|
if (!expr) throw new Error("Missing condition expression")
|
||||||
const eqMatch = expr.match(EQ_RE)
|
for (const operator of ["and", "or"]) {
|
||||||
if (eqMatch) {
|
const prefix = `${operator}(`
|
||||||
vars.push({ path: eqMatch[1]!, type: "string" })
|
if (expr.startsWith(prefix) && expr.endsWith(")")) {
|
||||||
|
const args = splitArgs(expr.slice(prefix.length, -1))
|
||||||
|
if (args.length === 0) throw new Error(`Invalid condition expression: ${expr}`)
|
||||||
|
for (const arg of args) extractCondition(arg, vars)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const stringCompareMatch = expr.match(STRING_COMPARE_RE)
|
||||||
|
if (stringCompareMatch) {
|
||||||
|
vars.push({ path: stringCompareMatch[1]!, type: "string" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const pathMatch = expr.match(PATH_RE)
|
const pathMatch = expr.match(PATH_RE)
|
||||||
@@ -28,43 +36,50 @@ function extractCondition(expr: string | undefined, vars: TemplateVariable[]) {
|
|||||||
throw new Error(`Invalid condition expression: ${expr}`)
|
throw new Error(`Invalid condition expression: ${expr}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractFromString(text: string, vars: TemplateVariable[]) {
|
function extractFromString(text: string, vars: TemplateVariable[], source = "template") {
|
||||||
for (const match of text.matchAll(IF_RE)) {
|
for (const token of getDirectiveTokens(text)) {
|
||||||
extractCondition(match[1]!, vars)
|
if (token.type === "if" || token.type === "elseif") {
|
||||||
|
try {
|
||||||
|
extractCondition(token.condition, vars)
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) throw new Error(`${source}: ${error.message}`)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (const match of text.matchAll(VAR_RE)) {
|
for (const match of text.matchAll(VAR_RE)) {
|
||||||
vars.push({ path: match[1]!, type: match[2] ?? "string" })
|
vars.push({ path: match[1]!, type: match[2] ?? "string" })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateIfBlocks(content: string, vars: TemplateVariable[]) {
|
function validateIfBlocks(content: string, vars: TemplateVariable[], source: string) {
|
||||||
const stack: { sawElse: boolean }[] = []
|
const stack: { sawElse: boolean }[] = []
|
||||||
|
|
||||||
for (const match of content.matchAll(DIRECTIVE_RE)) {
|
for (const token of getDirectiveTokens(content)) {
|
||||||
const directive = match[1]!
|
const directive = token.type
|
||||||
const condition = match[2]
|
const condition = token.condition
|
||||||
|
|
||||||
if (directive === "if") {
|
if (directive === "if") {
|
||||||
extractCondition(condition!, vars)
|
extractCondition(condition!, vars)
|
||||||
stack.push({ sawElse: false })
|
stack.push({ sawElse: false })
|
||||||
} else if (directive === "elseif") {
|
} else if (directive === "elseif") {
|
||||||
const frame = stack[stack.length - 1]
|
const frame = stack[stack.length - 1]
|
||||||
if (!frame) throw new Error("Unexpected <@elseif> without <@if>")
|
if (!frame) throw new Error(`${source}: Unexpected <@elseif> without <@if>`)
|
||||||
if (frame.sawElse) throw new Error("Unexpected <@elseif> after <@else>")
|
if (frame.sawElse) throw new Error(`${source}: Unexpected <@elseif> after <@else>`)
|
||||||
extractCondition(condition!, vars)
|
extractCondition(condition!, vars)
|
||||||
} else if (directive === "else") {
|
} else if (directive === "else") {
|
||||||
const frame = stack[stack.length - 1]
|
const frame = stack[stack.length - 1]
|
||||||
if (!frame) throw new Error("Unexpected <@else> without <@if>")
|
if (!frame) throw new Error(`${source}: Unexpected <@else> without <@if>`)
|
||||||
if (frame.sawElse) throw new Error("Unexpected duplicate <@else>")
|
if (frame.sawElse) throw new Error(`${source}: Unexpected duplicate <@else>`)
|
||||||
frame.sawElse = true
|
frame.sawElse = true
|
||||||
} else if (directive === "endif") {
|
} else if (directive === "endif") {
|
||||||
if (stack.length === 0) throw new Error("Unexpected <@endif> without <@if>")
|
if (stack.length === 0) throw new Error(`${source}: Unexpected <@endif> without <@if>`)
|
||||||
stack.pop()
|
stack.pop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stack.length > 0) {
|
if (stack.length > 0) {
|
||||||
throw new Error("Unmatched <@if> without <@endif>")
|
throw new Error(`${source}: Unmatched <@if> without <@endif>`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,21 +93,22 @@ function isUtf8Text(buffer: Buffer): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function walkDir(dirPath: string, vars: TemplateVariable[]) {
|
function walkDir(dirPath: string, vars: TemplateVariable[], rootPath: string) {
|
||||||
const entries = readdirSync(dirPath).sort()
|
const entries = readdirSync(dirPath).sort()
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const fullPath = join(dirPath, entry)
|
const fullPath = join(dirPath, entry)
|
||||||
extractFromString(entry, vars)
|
const relativePath = relative(rootPath, fullPath)
|
||||||
|
extractFromString(entry, vars, relativePath || entry)
|
||||||
|
|
||||||
const stat = statSync(fullPath)
|
const stat = statSync(fullPath)
|
||||||
if (stat.isDirectory()) {
|
if (stat.isDirectory()) {
|
||||||
walkDir(fullPath, vars)
|
walkDir(fullPath, vars, rootPath)
|
||||||
} else if (stat.isFile()) {
|
} else if (stat.isFile()) {
|
||||||
const content = readFileSync(fullPath)
|
const content = readFileSync(fullPath)
|
||||||
if (isUtf8Text(content)) {
|
if (isUtf8Text(content)) {
|
||||||
const text = content.toString("utf-8")
|
const text = content.toString("utf-8")
|
||||||
extractFromString(text, vars)
|
extractFromString(text, vars, relativePath || fullPath)
|
||||||
validateIfBlocks(text, vars)
|
validateIfBlocks(text, vars, relativePath || fullPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -100,7 +116,7 @@ function walkDir(dirPath: string, vars: TemplateVariable[]) {
|
|||||||
|
|
||||||
export function parse(dirPath: string): TemplateVariable[] {
|
export function parse(dirPath: string): TemplateVariable[] {
|
||||||
const vars: TemplateVariable[] = []
|
const vars: TemplateVariable[] = []
|
||||||
walkDir(dirPath, vars)
|
walkDir(dirPath, vars, dirPath)
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
return vars.filter(v => {
|
return vars.filter(v => {
|
||||||
const key = `${v.path}:${v.type}`
|
const key = `${v.path}:${v.type}`
|
||||||
|
|||||||
325
render.ts
325
render.ts
@@ -10,15 +10,15 @@ import {
|
|||||||
import { dirname, isAbsolute, relative, resolve as resolvePath } from "node:path"
|
import { dirname, isAbsolute, relative, resolve as resolvePath } from "node:path"
|
||||||
import { homedir } from "node:os"
|
import { homedir } from "node:os"
|
||||||
import { TextDecoder } from "node:util"
|
import { TextDecoder } from "node:util"
|
||||||
|
import { getDirectiveTokens, splitArgs, type DirectiveToken } from "./scanner"
|
||||||
|
|
||||||
const IF_PATH_RE = /^<@if\((.+?)\)>(.*)$/
|
const IF_PATH_RE = /^<@if\((.+?)\)>(.*)$/
|
||||||
const VAR_RE = /<@var\(context\.(.+?)(?::(\w+))?\)>/g
|
const VAR_RE = /<@var\(context\.(.+?)(?::(\w+))?\)>/g
|
||||||
const DIRECTIVE_RE = /<@(if|elseif|else|endif)(?:\((.+?)\))?>/g
|
const STRING_COMPARE_RE = /^(eq|neq)\(context\.(.+?),\s*"(.*)"\)$/
|
||||||
const EQ_RE = /^eq\(context\.(.+?),\s*"(.*)"\)$/
|
|
||||||
const PATH_RE = /^context\.(.+)$/
|
const PATH_RE = /^context\.(.+)$/
|
||||||
|
|
||||||
export type ReverseMapToken = {
|
export type ReverseMapToken = {
|
||||||
kind: "path" | "content"
|
kind: "path" | "content" | "conditional"
|
||||||
result: string
|
result: string
|
||||||
token: string
|
token: string
|
||||||
contextPath?: string
|
contextPath?: string
|
||||||
@@ -28,6 +28,12 @@ export type ReverseMapToken = {
|
|||||||
start: number
|
start: number
|
||||||
end: number
|
end: number
|
||||||
}
|
}
|
||||||
|
activeRange?: {
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
}
|
||||||
|
before?: string
|
||||||
|
after?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ReverseMapFile = {
|
export type ReverseMapFile = {
|
||||||
@@ -36,9 +42,17 @@ export type ReverseMapFile = {
|
|||||||
tokens: ReverseMapToken[]
|
tokens: ReverseMapToken[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ReverseMapStoredTemplate = {
|
||||||
|
kind: "directory" | "file"
|
||||||
|
templatePath: string
|
||||||
|
encoding?: "utf8" | "base64"
|
||||||
|
content?: string
|
||||||
|
}
|
||||||
|
|
||||||
export type ReverseMapManifest = {
|
export type ReverseMapManifest = {
|
||||||
version: 1
|
version: 1
|
||||||
files: ReverseMapFile[]
|
files: ReverseMapFile[]
|
||||||
|
skipped: ReverseMapStoredTemplate[]
|
||||||
tokens: Record<string, string[]>
|
tokens: Record<string, string[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,9 +68,20 @@ type RenderState = {
|
|||||||
|
|
||||||
function evalCondition(expr: string | undefined, context: Record<string, unknown>): boolean {
|
function evalCondition(expr: string | undefined, context: Record<string, unknown>): boolean {
|
||||||
if (!expr) throw new Error("Missing condition expression")
|
if (!expr) throw new Error("Missing condition expression")
|
||||||
const eqMatch = expr.match(EQ_RE)
|
for (const operator of ["and", "or"] as const) {
|
||||||
if (eqMatch) {
|
const prefix = `${operator}(`
|
||||||
return resolveContext(context, eqMatch[1]!) === eqMatch[2]
|
if (expr.startsWith(prefix) && expr.endsWith(")")) {
|
||||||
|
const args = splitArgs(expr.slice(prefix.length, -1))
|
||||||
|
if (args.length === 0) throw new Error(`Invalid condition expression: ${expr}`)
|
||||||
|
return operator === "and"
|
||||||
|
? args.every(arg => evalCondition(arg, context))
|
||||||
|
: args.some(arg => evalCondition(arg, context))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
const pathMatch = expr.match(PATH_RE)
|
||||||
if (pathMatch) {
|
if (pathMatch) {
|
||||||
@@ -110,7 +135,7 @@ function resolveOutputPath(destRoot: string, outputName: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createReverseMapManifest(): ReverseMapManifest {
|
function createReverseMapManifest(): ReverseMapManifest {
|
||||||
return { version: 1, files: [], tokens: {} }
|
return { version: 1, files: [], skipped: [], tokens: {} }
|
||||||
}
|
}
|
||||||
|
|
||||||
function addReverseMapToken(
|
function addReverseMapToken(
|
||||||
@@ -125,7 +150,18 @@ function addReverseMapToken(
|
|||||||
state.manifest.tokens[token.result] = tokens
|
state.manifest.tokens[token.result] = tokens
|
||||||
}
|
}
|
||||||
|
|
||||||
function getReverseMapPath(destRoot: string, reverseMap: boolean | string): string {
|
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")
|
if (reverseMap === true) return resolveOutputPath(destRoot, ".tdir-map.json")
|
||||||
return resolveOutputPath(destRoot, reverseMap)
|
return resolveOutputPath(destRoot, reverseMap)
|
||||||
}
|
}
|
||||||
@@ -176,70 +212,174 @@ function isUtf8Text(buffer: Buffer): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Each stack frame tracks: did any branch match yet, is the current branch active
|
type TextNode = {
|
||||||
type IfFrame = { matched: boolean; active: boolean; sawElse: boolean }
|
type: "text"
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
function processIfBlocks(content: string, context: Record<string, unknown>): string {
|
type IfBranch = {
|
||||||
|
type: "if" | "elseif" | "else"
|
||||||
|
condition?: string
|
||||||
|
nodes: TemplateNode[]
|
||||||
|
contentStart: number
|
||||||
|
contentEnd: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type IfNode = {
|
||||||
|
type: "if"
|
||||||
|
sourceStart: number
|
||||||
|
sourceEnd: number
|
||||||
|
source: string
|
||||||
|
branches: IfBranch[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type TemplateNode = TextNode | IfNode
|
||||||
|
|
||||||
|
type ConditionalRender = {
|
||||||
|
result: string
|
||||||
|
token: string
|
||||||
|
range: {
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
}
|
||||||
|
activeRange: {
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNodes(
|
||||||
|
content: string,
|
||||||
|
tokens: DirectiveToken[],
|
||||||
|
tokenIndex: number,
|
||||||
|
pos: number,
|
||||||
|
stopTypes: DirectiveToken["type"][],
|
||||||
|
): {
|
||||||
|
nodes: TemplateNode[]
|
||||||
|
pos: number
|
||||||
|
tokenIndex: number
|
||||||
|
stop?: DirectiveToken
|
||||||
|
} {
|
||||||
|
const nodes: TemplateNode[] = []
|
||||||
|
|
||||||
|
while (tokenIndex < tokens.length) {
|
||||||
|
const token = tokens[tokenIndex]!
|
||||||
|
if (stopTypes.includes(token.type)) {
|
||||||
|
if (token.index > pos) nodes.push({ type: "text", text: content.slice(pos, token.index) })
|
||||||
|
return { nodes, pos: token.index, tokenIndex, stop: token }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.type !== "if") {
|
||||||
|
throw new Error(`Unexpected <@${token.type}> without <@if>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.index > pos) nodes.push({ type: "text", text: content.slice(pos, token.index) })
|
||||||
|
const parsed = parseIfNode(content, tokens, tokenIndex)
|
||||||
|
nodes.push(parsed.node)
|
||||||
|
tokenIndex = parsed.tokenIndex
|
||||||
|
pos = parsed.pos
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pos < content.length) nodes.push({ type: "text", text: content.slice(pos) })
|
||||||
|
return { nodes, pos: content.length, tokenIndex }
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIfNode(content: string, tokens: DirectiveToken[], tokenIndex: number) {
|
||||||
|
const firstToken = tokens[tokenIndex]!
|
||||||
|
const sourceStart = firstToken.index
|
||||||
|
const branches: IfBranch[] = []
|
||||||
|
let branchType: IfBranch["type"] = "if"
|
||||||
|
let branchCondition = firstToken.condition
|
||||||
|
let branchContentStart = firstToken.end
|
||||||
|
tokenIndex += 1
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const parsed = parseNodes(content, tokens, tokenIndex, branchContentStart, ["elseif", "else", "endif"])
|
||||||
|
if (!parsed.stop) throw new Error("Unmatched <@if> without <@endif>")
|
||||||
|
|
||||||
|
branches.push({
|
||||||
|
type: branchType,
|
||||||
|
condition: branchCondition,
|
||||||
|
nodes: parsed.nodes,
|
||||||
|
contentStart: branchContentStart,
|
||||||
|
contentEnd: parsed.pos,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (parsed.stop.type === "endif") {
|
||||||
|
const sourceEnd = parsed.stop.end
|
||||||
|
return {
|
||||||
|
node: {
|
||||||
|
type: "if" as const,
|
||||||
|
sourceStart,
|
||||||
|
sourceEnd,
|
||||||
|
source: content.slice(sourceStart, sourceEnd),
|
||||||
|
branches,
|
||||||
|
},
|
||||||
|
pos: sourceEnd,
|
||||||
|
tokenIndex: parsed.tokenIndex + 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
branchType = parsed.stop.type
|
||||||
|
branchCondition = parsed.stop.condition
|
||||||
|
branchContentStart = parsed.stop.end
|
||||||
|
tokenIndex = parsed.tokenIndex + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveBranch(node: IfNode, context: Record<string, unknown>): IfBranch | undefined {
|
||||||
|
for (const branch of node.branches) {
|
||||||
|
if (branch.type === "else" || evalCondition(branch.condition, context)) return branch
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNodes(
|
||||||
|
nodes: TemplateNode[],
|
||||||
|
context: Record<string, unknown>,
|
||||||
|
conditionalTokens: ConditionalRender[],
|
||||||
|
outputStart = 0,
|
||||||
|
): string {
|
||||||
let result = ""
|
let result = ""
|
||||||
let pos = 0
|
|
||||||
const stack: IfFrame[] = []
|
|
||||||
|
|
||||||
const re = new RegExp(DIRECTIVE_RE.source, "g")
|
for (const node of nodes) {
|
||||||
let match: RegExpExecArray | null
|
if (node.type === "text") {
|
||||||
|
result += node.text
|
||||||
function isEmitting(): boolean {
|
continue
|
||||||
return stack.every(f => f.active)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
while ((match = re.exec(content)) !== null) {
|
const activeBranch = getActiveBranch(node, context)
|
||||||
const directive = match[1]!
|
const start = outputStart + result.length
|
||||||
const condPath = match[2]
|
const renderedBranch = activeBranch
|
||||||
|
? renderNodes(activeBranch.nodes, context, conditionalTokens, start)
|
||||||
if (directive === "if") {
|
: ""
|
||||||
if (isEmitting()) result += content.slice(pos, match.index)
|
result += renderedBranch
|
||||||
const truthy = evalCondition(condPath, context)
|
conditionalTokens.push({
|
||||||
stack.push({ matched: truthy, active: truthy, sawElse: false })
|
result: renderedBranch,
|
||||||
pos = re.lastIndex
|
token: node.source,
|
||||||
} else if (directive === "elseif") {
|
range: { start, end: start + renderedBranch.length },
|
||||||
if (stack.length === 0) throw new Error("Unexpected <@elseif> without <@if>")
|
activeRange: activeBranch
|
||||||
const top = stack[stack.length - 1]!
|
? {
|
||||||
if (top.sawElse) throw new Error("Unexpected <@elseif> after <@else>")
|
start: activeBranch.contentStart - node.sourceStart,
|
||||||
// Emit text before this directive if current branch was active
|
end: activeBranch.contentEnd - node.sourceStart,
|
||||||
if (isEmitting()) result += content.slice(pos, match.index)
|
|
||||||
if (top.matched) {
|
|
||||||
// A previous branch already matched — skip this one
|
|
||||||
top.active = false
|
|
||||||
} else {
|
|
||||||
const truthy = evalCondition(condPath, context)
|
|
||||||
top.matched = truthy
|
|
||||||
top.active = truthy
|
|
||||||
}
|
|
||||||
pos = re.lastIndex
|
|
||||||
} else if (directive === "else") {
|
|
||||||
if (stack.length === 0) throw new Error("Unexpected <@else> without <@if>")
|
|
||||||
const top = stack[stack.length - 1]!
|
|
||||||
if (top.sawElse) throw new Error("Unexpected duplicate <@else>")
|
|
||||||
if (isEmitting()) result += content.slice(pos, match.index)
|
|
||||||
top.active = !top.matched
|
|
||||||
top.matched = true
|
|
||||||
top.sawElse = true
|
|
||||||
pos = re.lastIndex
|
|
||||||
} else if (directive === "endif") {
|
|
||||||
if (stack.length === 0) throw new Error("Unexpected <@endif> without <@if>")
|
|
||||||
if (isEmitting()) result += content.slice(pos, match.index)
|
|
||||||
stack.pop()
|
|
||||||
pos = re.lastIndex
|
|
||||||
}
|
}
|
||||||
|
: { start: node.source.length, end: node.source.length },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stack.length > 0) {
|
|
||||||
throw new Error("Unmatched <@if> without <@endif>")
|
|
||||||
}
|
|
||||||
|
|
||||||
result += content.slice(pos)
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function processIfBlocksWithMap(content: string, context: Record<string, unknown>) {
|
||||||
|
const tokens = getDirectiveTokens(content)
|
||||||
|
const parsed = parseNodes(content, tokens, 0, 0, [])
|
||||||
|
const conditionalTokens: ConditionalRender[] = []
|
||||||
|
return {
|
||||||
|
content: renderNodes(parsed.nodes, context, conditionalTokens),
|
||||||
|
conditionalTokens,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderContent(content: string, context: Record<string, unknown>): string {
|
function renderContent(content: string, context: Record<string, unknown>): string {
|
||||||
return renderContentWithMap(content, context)
|
return renderContentWithMap(content, context)
|
||||||
}
|
}
|
||||||
@@ -250,7 +390,28 @@ function renderContentWithMap(
|
|||||||
state?: RenderState,
|
state?: RenderState,
|
||||||
file?: ReverseMapFile,
|
file?: ReverseMapFile,
|
||||||
): string {
|
): string {
|
||||||
const processed = processIfBlocks(content, context)
|
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) {
|
||||||
|
addReverseMapToken(state, file, {
|
||||||
|
kind: "conditional",
|
||||||
|
result: token.result,
|
||||||
|
token: token.token,
|
||||||
|
outputPath: file?.outputPath ?? "",
|
||||||
|
templatePath: file?.templatePath ?? "",
|
||||||
|
range: token.range,
|
||||||
|
activeRange: token.activeRange,
|
||||||
|
before: processed.slice(0, token.range.start),
|
||||||
|
after: processed.slice(token.range.end),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
let result = ""
|
let result = ""
|
||||||
let pos = 0
|
let pos = 0
|
||||||
|
|
||||||
@@ -321,6 +482,39 @@ function validateOutputPaths(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function storeSkippedTemplate(srcPath: string, state: RenderState) {
|
||||||
|
if (!state.manifest) return
|
||||||
|
|
||||||
|
const stat = statSync(srcPath)
|
||||||
|
const templatePath = relative(state.sourceRoot, srcPath)
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
state.manifest.skipped.push({ kind: "directory", templatePath })
|
||||||
|
for (const entry of readdirSync(srcPath).sort()) {
|
||||||
|
storeSkippedTemplate(resolvePath(srcPath, entry), state)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stat.isFile()) return
|
||||||
|
|
||||||
|
const content = readFileSync(srcPath)
|
||||||
|
if (isUtf8Text(content)) {
|
||||||
|
state.manifest.skipped.push({
|
||||||
|
kind: "file",
|
||||||
|
templatePath,
|
||||||
|
encoding: "utf8",
|
||||||
|
content: content.toString("utf-8"),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
state.manifest.skipped.push({
|
||||||
|
kind: "file",
|
||||||
|
templatePath,
|
||||||
|
encoding: "base64",
|
||||||
|
content: content.toString("base64"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderDirInner(
|
function renderDirInner(
|
||||||
srcDir: string,
|
srcDir: string,
|
||||||
destDir: string,
|
destDir: string,
|
||||||
@@ -340,7 +534,10 @@ function renderDirInner(
|
|||||||
tokens: [],
|
tokens: [],
|
||||||
}
|
}
|
||||||
const outputName = getOutputName(entry, context, state, tempFile)
|
const outputName = getOutputName(entry, context, state, tempFile)
|
||||||
if (outputName === null) continue
|
if (outputName === null) {
|
||||||
|
storeSkippedTemplate(srcPath, state)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
const destPath = resolveOutputPath(destDir, outputName)
|
const destPath = resolveOutputPath(destDir, outputName)
|
||||||
const outputPath = relative(state.destRoot, destPath)
|
const outputPath = relative(state.destRoot, destPath)
|
||||||
@@ -362,7 +559,7 @@ function renderDirInner(
|
|||||||
} else {
|
} else {
|
||||||
copyFileSync(srcPath, destPath)
|
copyFileSync(srcPath, destPath)
|
||||||
}
|
}
|
||||||
if (tempFile.tokens.length > 0) state.manifest?.files.push(tempFile)
|
if (state.manifest) state.manifest.files.push(tempFile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
314
reverse.ts
314
reverse.ts
@@ -3,15 +3,17 @@ import {
|
|||||||
existsSync,
|
existsSync,
|
||||||
mkdirSync,
|
mkdirSync,
|
||||||
readFileSync,
|
readFileSync,
|
||||||
|
readdirSync,
|
||||||
statSync,
|
statSync,
|
||||||
writeFileSync,
|
writeFileSync,
|
||||||
} from "node:fs"
|
} from "node:fs"
|
||||||
import { dirname, isAbsolute, relative, resolve as resolvePath } from "node:path"
|
import { dirname, isAbsolute, relative, resolve as resolvePath } from "node:path"
|
||||||
import { TextDecoder } from "node:util"
|
import { TextDecoder } from "node:util"
|
||||||
import type { ReverseMapFile, ReverseMapManifest, ReverseMapToken } from "./render"
|
import type { ReverseMapFile, ReverseMapManifest, ReverseMapStoredTemplate, ReverseMapToken } from "./render"
|
||||||
|
|
||||||
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
|
||||||
@@ -71,17 +118,73 @@ function replaceFirst(content: string, token: ReverseMapToken): string | null {
|
|||||||
return `${content.slice(0, index)}${token.token}${content.slice(index + token.result.length)}`
|
return `${content.slice(0, index)}${token.token}${content.slice(index + token.result.length)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyActiveBranch(token: ReverseMapToken, branchContent: string): string {
|
||||||
|
if (!token.activeRange) return token.token
|
||||||
|
return [
|
||||||
|
token.token.slice(0, token.activeRange.start),
|
||||||
|
branchContent,
|
||||||
|
token.token.slice(token.activeRange.end),
|
||||||
|
].join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
function findPrefixEnd(content: string, before: string): number {
|
||||||
|
if (before === "") return 0
|
||||||
|
let candidate = before
|
||||||
|
while (candidate.length >= 8) {
|
||||||
|
const index = content.indexOf(candidate)
|
||||||
|
if (index !== -1) return index + candidate.length
|
||||||
|
candidate = candidate.slice(-Math.max(1, Math.floor(candidate.length / 2)))
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
function findSuffixStart(content: string, after: string, from: number): number {
|
||||||
|
if (after === "") return content.length
|
||||||
|
let candidate = after
|
||||||
|
while (candidate.length >= 8) {
|
||||||
|
const index = content.indexOf(candidate, from)
|
||||||
|
if (index !== -1) return index
|
||||||
|
candidate = candidate.slice(0, Math.floor(candidate.length / 2))
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceConditional(content: string, token: ReverseMapToken): string | null {
|
||||||
|
const exactIndex = content.indexOf(token.result)
|
||||||
|
if (exactIndex !== -1) {
|
||||||
|
return [
|
||||||
|
content.slice(0, exactIndex),
|
||||||
|
token.token,
|
||||||
|
content.slice(exactIndex + token.result.length),
|
||||||
|
].join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.before === undefined || token.after === undefined) return null
|
||||||
|
|
||||||
|
const branchStart = findPrefixEnd(content, token.before)
|
||||||
|
if (branchStart === -1) return null
|
||||||
|
|
||||||
|
const afterIndex = findSuffixStart(content, token.after, branchStart)
|
||||||
|
if (afterIndex === -1) return null
|
||||||
|
|
||||||
|
return [
|
||||||
|
content.slice(0, branchStart),
|
||||||
|
applyActiveBranch(token, content.slice(branchStart, afterIndex)),
|
||||||
|
content.slice(afterIndex),
|
||||||
|
].join("")
|
||||||
|
}
|
||||||
|
|
||||||
function reverseContent(
|
function reverseContent(
|
||||||
content: string,
|
content: string,
|
||||||
file: ReverseMapFile,
|
file: ReverseMapFile,
|
||||||
warnings: ReverseWarning[],
|
warnings: ReverseWarning[],
|
||||||
): string {
|
): string {
|
||||||
const tokens = file.tokens
|
const contentTokens = file.tokens
|
||||||
.filter(token => token.kind === "content")
|
.filter(token => token.kind === "content")
|
||||||
.sort((a, b) => (b.range?.start ?? -1) - (a.range?.start ?? -1))
|
.sort((a, b) => (b.range?.start ?? -1) - (a.range?.start ?? -1))
|
||||||
|
|
||||||
let reversed = content
|
let reversed = content
|
||||||
for (const token of tokens) {
|
for (const token of contentTokens) {
|
||||||
const rangeResult = replaceAtRange(reversed, token)
|
const rangeResult = replaceAtRange(reversed, token)
|
||||||
if (rangeResult !== null) {
|
if (rangeResult !== null) {
|
||||||
reversed = rangeResult
|
reversed = rangeResult
|
||||||
@@ -101,9 +204,194 @@ function reverseContent(
|
|||||||
message: "Rendered value was not found; token was not restored",
|
message: "Rendered value was not found; token was not restored",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const conditionalTokens = file.tokens
|
||||||
|
.filter(token => token.kind === "conditional")
|
||||||
|
.sort((a, b) => (b.range?.start ?? -1) - (a.range?.start ?? -1))
|
||||||
|
|
||||||
|
for (const token of conditionalTokens) {
|
||||||
|
const result = replaceConditional(reversed, token)
|
||||||
|
if (result !== null) {
|
||||||
|
reversed = result
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
warnings.push({
|
||||||
|
outputPath: file.outputPath,
|
||||||
|
token: token.token,
|
||||||
|
result: token.result,
|
||||||
|
message: "Rendered conditional block was not found; block was not restored",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return reversed
|
return reversed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function writeSkippedTemplate(
|
||||||
|
templateRoot: string,
|
||||||
|
skipped: ReverseMapStoredTemplate,
|
||||||
|
): number {
|
||||||
|
const templatePath = resolveInside(templateRoot, skipped.templatePath)
|
||||||
|
if (skipped.kind === "directory") {
|
||||||
|
mkdirSync(templatePath, { recursive: true })
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync(dirname(templatePath), { recursive: true })
|
||||||
|
const content = skipped.encoding === "base64"
|
||||||
|
? Buffer.from(skipped.content ?? "", "base64")
|
||||||
|
: skipped.content ?? ""
|
||||||
|
writeFileSync(templatePath, content)
|
||||||
|
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("/")
|
||||||
|
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, 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()) {
|
||||||
|
pending.push(path)
|
||||||
|
} else if (stat.isFile()) {
|
||||||
|
files.push(normalizePath(relative(root, path)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyIncludedRenderedFiles(
|
||||||
|
renderedRoot: string,
|
||||||
|
templateRoot: string,
|
||||||
|
includedOutputPaths: string[],
|
||||||
|
directoryMap: Map<string, string>,
|
||||||
|
manifest: ReverseMapManifest,
|
||||||
|
): number {
|
||||||
|
let filesWritten = 0
|
||||||
|
|
||||||
|
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(
|
export function reverseDir(
|
||||||
renderedDir: string,
|
renderedDir: string,
|
||||||
templateDir: string,
|
templateDir: string,
|
||||||
@@ -115,6 +403,14 @@ export function reverseDir(
|
|||||||
? resolvePath(renderedRoot, options.mapPath)
|
? resolvePath(renderedRoot, options.mapPath)
|
||||||
: resolvePath(renderedRoot, ".tdir-map.json")
|
: resolvePath(renderedRoot, ".tdir-map.json")
|
||||||
const manifest = readManifest(mapPath)
|
const manifest = readManifest(mapPath)
|
||||||
|
const directoryMap = buildDirectoryMap(manifest)
|
||||||
|
const includedOutputPaths = getIncludedRenderedFiles(
|
||||||
|
renderedRoot,
|
||||||
|
templateRoot,
|
||||||
|
mapPath,
|
||||||
|
manifest,
|
||||||
|
options.include,
|
||||||
|
)
|
||||||
const warnings: ReverseWarning[] = []
|
const warnings: ReverseWarning[] = []
|
||||||
let filesWritten = 0
|
let filesWritten = 0
|
||||||
|
|
||||||
@@ -154,5 +450,17 @@ export function reverseDir(
|
|||||||
filesWritten += 1
|
filesWritten += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const skipped of manifest.skipped ?? []) {
|
||||||
|
filesWritten += writeSkippedTemplate(templateRoot, skipped)
|
||||||
|
}
|
||||||
|
|
||||||
|
filesWritten += copyIncludedRenderedFiles(
|
||||||
|
renderedRoot,
|
||||||
|
templateRoot,
|
||||||
|
includedOutputPaths,
|
||||||
|
directoryMap,
|
||||||
|
manifest,
|
||||||
|
)
|
||||||
|
|
||||||
return { filesWritten, warnings }
|
return { filesWritten, warnings }
|
||||||
}
|
}
|
||||||
|
|||||||
99
scanner.ts
Normal file
99
scanner.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
export type DirectiveToken = {
|
||||||
|
type: "if" | "elseif" | "else" | "endif"
|
||||||
|
condition?: string
|
||||||
|
index: number
|
||||||
|
end: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCondition(text: string, start: number): { condition: string; end: number } | null {
|
||||||
|
let depth = 0
|
||||||
|
let inString = false
|
||||||
|
let escaped = false
|
||||||
|
|
||||||
|
for (let i = start; i < text.length; i++) {
|
||||||
|
const char = text[i]!
|
||||||
|
if (escaped) {
|
||||||
|
escaped = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (char === "\\") {
|
||||||
|
escaped = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (char === "\"") {
|
||||||
|
inString = !inString
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (inString) continue
|
||||||
|
if (char === "(") {
|
||||||
|
depth += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (char === ")") {
|
||||||
|
depth -= 1
|
||||||
|
if (depth === 0 && text[i + 1] === ">") {
|
||||||
|
return { condition: text.slice(start + 1, i), end: i + 2 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDirectiveTokens(text: string): DirectiveToken[] {
|
||||||
|
const tokens: DirectiveToken[] = []
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
if (text[i] !== "<" || text[i + 1] !== "@") continue
|
||||||
|
const rest = text.slice(i + 2)
|
||||||
|
const type = ["elseif", "endif", "else", "if"].find(name => rest.startsWith(name)) as DirectiveToken["type"] | undefined
|
||||||
|
if (!type) continue
|
||||||
|
const afterName = i + 2 + type.length
|
||||||
|
if ((type === "if" || type === "elseif") && text[afterName] === "(") {
|
||||||
|
const parsed = readCondition(text, afterName)
|
||||||
|
if (!parsed) continue
|
||||||
|
tokens.push({ type, condition: parsed.condition, index: i, end: parsed.end })
|
||||||
|
i = parsed.end - 1
|
||||||
|
} else if ((type === "else" || type === "endif") && text[afterName] === ">") {
|
||||||
|
tokens.push({ type, index: i, end: afterName + 1 })
|
||||||
|
i = afterName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitArgs(args: string): string[] {
|
||||||
|
const result: string[] = []
|
||||||
|
let current = ""
|
||||||
|
let depth = 0
|
||||||
|
let inString = false
|
||||||
|
let escaped = false
|
||||||
|
|
||||||
|
for (const char of args) {
|
||||||
|
if (escaped) {
|
||||||
|
current += char
|
||||||
|
escaped = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (char === "\\") {
|
||||||
|
current += char
|
||||||
|
escaped = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (char === "\"") {
|
||||||
|
current += char
|
||||||
|
inString = !inString
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!inString && char === "(") depth += 1
|
||||||
|
if (!inString && char === ")") depth -= 1
|
||||||
|
if (!inString && depth === 0 && char === ",") {
|
||||||
|
result.push(current.trim())
|
||||||
|
current = ""
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
current += char
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.trim() !== "") result.push(current.trim())
|
||||||
|
return result
|
||||||
|
}
|
||||||
3
testdata/and_or_in_file/test.txt
vendored
Normal file
3
testdata/and_or_in_file/test.txt
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<@if(and(context.enabled,eq(context.kind,"web")))>and<@else>not-and<@endif>
|
||||||
|
<@if(or(context.enabled,neq(context.kind,"web")))>or<@else>not-or<@endif>
|
||||||
|
<@if(and(or(context.enabled,context.fallback),neq(context.kind,"native")))>nested<@else>not-nested<@endif>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
match
|
||||||
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
|
||||||
7
testdata/neq_multiline_block/test.txt
vendored
Normal file
7
testdata/neq_multiline_block/test.txt
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<@if(neq(context.project.frontend,"none"))>
|
||||||
|
bundev = {
|
||||||
|
exec = "bun dev";
|
||||||
|
cwd = "./apps/web";
|
||||||
|
after= ["devenv:processes:air@started"];
|
||||||
|
};
|
||||||
|
<@endif>
|
||||||
@@ -6,5 +6,5 @@
|
|||||||
"emitDeclarationOnly": true,
|
"emitDeclarationOnly": true,
|
||||||
"outDir": "./dist"
|
"outDir": "./dist"
|
||||||
},
|
},
|
||||||
"include": ["index.ts", "parser.ts", "render.ts", "reverse.ts"]
|
"include": ["index.ts", "parser.ts", "render.ts", "reverse.ts", "scanner.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user