Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
101769a4b2 | ||
|
|
ed5a666ad3 | ||
|
|
452d872954 | ||
|
|
e900729c8f | ||
|
|
fe3489e217 | ||
|
|
9ed1324e06 | ||
|
|
d8536d83de | ||
|
|
0fab9c8d38 | ||
|
|
e16fc8b482 | ||
|
|
7d01b2f7c9 | ||
|
|
0412cea241 | ||
|
|
0a512cdbc3 | ||
|
|
2971c87618 | ||
|
|
af0c25e64b |
@@ -7,7 +7,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish:
|
publish:
|
||||||
runs-on: x86
|
runs-on: local
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -21,6 +21,10 @@ jobs:
|
|||||||
node-version: 24
|
node-version: 24
|
||||||
registry-url: "https://registry.npmjs.org"
|
registry-url: "https://registry.npmjs.org"
|
||||||
|
|
||||||
|
# Stage-only tokens require `npm stage publish` (npm >= 11.15.0).
|
||||||
|
- name: Update npm
|
||||||
|
run: npm install -g npm@^11.15.0
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
@@ -30,10 +34,13 @@ jobs:
|
|||||||
TAG_NAME="${GITHUB_REF_NAME:-${GITHUB_REF#refs/tags/}}"
|
TAG_NAME="${GITHUB_REF_NAME:-${GITHUB_REF#refs/tags/}}"
|
||||||
test "$PACKAGE_VERSION" = "$TAG_NAME"
|
test "$PACKAGE_VERSION" = "$TAG_NAME"
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: bun test
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: bun run build
|
run: bun run build
|
||||||
|
|
||||||
- name: Publish
|
- name: Stage publish
|
||||||
run: npm publish --access public
|
run: npm stage publish --access public
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -125,6 +131,31 @@ render("./output", { web: "not a boolean", header: { show: true, title: "Hi" } }
|
|||||||
|
|
||||||
For safety, tdir refuses to render into the filesystem root, the current working directory, the home directory, or any directory that overlaps the template source. Dynamic file and directory names are also resolved against the output directory and cannot write outside it.
|
For safety, tdir refuses to render into the filesystem root, the current working directory, the home directory, or any directory that overlaps the template source. Dynamic file and directory names are also resolved against the output directory and cannot write outside it.
|
||||||
|
|
||||||
|
Pass `{ overwrite: false }` to keep existing work safe, e.g. in a scaffolding CLI. tdir then throws `TargetExistsError` instead of clearing a target that exists and is not empty (or is a file); a missing or empty target is rendered as usual:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { TargetExistsError } from "@gregorlohaus/tdir"
|
||||||
|
|
||||||
|
try {
|
||||||
|
render("./my-app", context, { overwrite: false })
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof TargetExistsError) console.error("./my-app already exists")
|
||||||
|
else throw error
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Renaming output files
|
||||||
|
|
||||||
|
Some files can't be stored in a template under their real name, e.g. npm never publishes `.gitignore` or `.npmrc` files. Store them under another name and map it back with `rename`, which applies to rendered file and directory names:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
render("./output", context, { rename: { _gitignore: ".gitignore", _npmrc: ".npmrc" } })
|
||||||
|
// templates/_gitignore -> output/.gitignore
|
||||||
|
// templates/<@if(context.web)>web/_gitignore -> output/web/.gitignore
|
||||||
|
```
|
||||||
|
|
||||||
|
Reverse maps record the renamed output path, so `tdir reverse` writes edits back to `_gitignore`.
|
||||||
|
|
||||||
## Reverse maps
|
## Reverse maps
|
||||||
|
|
||||||
Pass `{ reverseMap: true }` as the third render argument to write `.tdir-map.json` into the output directory:
|
Pass `{ reverseMap: true }` as the third render argument to write `.tdir-map.json` into the output directory:
|
||||||
@@ -142,7 +173,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 +191,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 +239,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
|
||||||
|
|
||||||
|
|||||||
@@ -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}`)
|
||||||
|
|||||||
+9
-9
@@ -3,11 +3,11 @@
|
|||||||
"devenv": {
|
"devenv": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"dir": "src/modules",
|
"dir": "src/modules",
|
||||||
"lastModified": 1776271913,
|
"lastModified": 1790701375,
|
||||||
"narHash": "sha256-j/1hNdZSci/jrYEHj3/F24EI/YE8DL0OzfMWZUgpMig=",
|
"narHash": "sha256-0FpLHIRDYiyo3EMTnMQSobZQknZaa3JuWo6vmMCK76o=",
|
||||||
"owner": "cachix",
|
"owner": "cachix",
|
||||||
"repo": "devenv",
|
"repo": "devenv",
|
||||||
"rev": "2012662a89ff2ce92044151d7bbf3894eec5620a",
|
"rev": "fe20b5cba7ab5e93ae73f956a8d3efc50e1753f4",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -22,11 +22,11 @@
|
|||||||
"nixpkgs-src": "nixpkgs-src"
|
"nixpkgs-src": "nixpkgs-src"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1776097194,
|
"lastModified": 1789506231,
|
||||||
"narHash": "sha256-XD4DsgNcfXC5nlCxlAcCP5hSjTYlgLXEIoTj7fKkQg4=",
|
"narHash": "sha256-vNkJbtmqyBfFhn/HnzQo4ROuoyYWuy/allqTfJb4kMY=",
|
||||||
"owner": "cachix",
|
"owner": "cachix",
|
||||||
"repo": "devenv-nixpkgs",
|
"repo": "devenv-nixpkgs",
|
||||||
"rev": "6e8a07b02f6f8557ffab71274feac9827bcc2532",
|
"rev": "c2f38fe7f9e04d9aadd354d380f2bd40531d9737",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -39,11 +39,11 @@
|
|||||||
"nixpkgs-src": {
|
"nixpkgs-src": {
|
||||||
"flake": false,
|
"flake": false,
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1775888245,
|
"lastModified": 1789370336,
|
||||||
"narHash": "sha256-nwASzrRDD1JBEu/o8ekKYEXm/oJW6EMCzCRdrwcLe90=",
|
"narHash": "sha256-6RSEDHIWQtesQKWSu5qRai8L2h4KgCgMEfJHstW99G4=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "13043924aaa7375ce482ebe2494338e058282925",
|
"rev": "c7def046b9a883d46974757852106483d741586f",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
+250
-2
@@ -1,5 +1,5 @@
|
|||||||
import { expect, test, beforeEach, afterEach } from 'bun:test'
|
import { expect, test, beforeEach, afterEach } from 'bun:test'
|
||||||
import { initRenderer, reverseDir, SchemaMismatchError } from "./index";
|
import { initRenderer, reverseDir, SchemaMismatchError, TargetExistsError } from "./index";
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"
|
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"
|
||||||
import { basename, join } from "node:path"
|
import { basename, join } from "node:path"
|
||||||
@@ -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,159 @@ 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)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rename maps output names and records them in the reverse map", () => {
|
||||||
|
const templateDir = join(tmp, "template")
|
||||||
|
mkdirSync(join(templateDir, "<@if(context.web)>web"), { recursive: true })
|
||||||
|
writeFileSync(join(templateDir, "_gitignore"), "node_modules\n")
|
||||||
|
writeFileSync(join(templateDir, "<@if(context.web)>web", "_gitignore"), "dist\n")
|
||||||
|
const render = initRenderer(templateDir)(z.object({ web: z.boolean() }))
|
||||||
|
const out = join(tmp, "out")
|
||||||
|
|
||||||
|
render(out, { web: true }, { reverseMap: true, rename: { _gitignore: ".gitignore" } })
|
||||||
|
|
||||||
|
expect(readFileSync(join(out, ".gitignore"), "utf-8")).toBe("node_modules\n")
|
||||||
|
expect(readFileSync(join(out, "web", ".gitignore"), "utf-8")).toBe("dist\n")
|
||||||
|
expect(existsSync(join(out, "_gitignore"))).toBe(false)
|
||||||
|
const manifest = JSON.parse(readFileSync(join(out, ".tdir-map.json"), "utf-8"))
|
||||||
|
expect(manifest.files).toContainEqual(expect.objectContaining({ outputPath: ".gitignore", templatePath: "_gitignore" }))
|
||||||
|
expect(manifest.files).toContainEqual(expect.objectContaining({
|
||||||
|
outputPath: join("web", ".gitignore"),
|
||||||
|
templatePath: join("<@if(context.web)>web", "_gitignore"),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const reversedDir = join(tmp, "reversed")
|
||||||
|
reverseDir(out, reversedDir)
|
||||||
|
expect(readFileSync(join(reversedDir, "_gitignore"), "utf-8")).toBe("node_modules\n")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rename applies to directories", () => {
|
||||||
|
const templateDir = join(tmp, "template")
|
||||||
|
mkdirSync(join(templateDir, "_github"), { recursive: true })
|
||||||
|
writeFileSync(join(templateDir, "_github", "ci.yml"), "on: push\n")
|
||||||
|
const render = initRenderer(templateDir)(z.object({}))
|
||||||
|
const out = join(tmp, "out")
|
||||||
|
|
||||||
|
render(out, {}, { rename: { _github: ".github" } })
|
||||||
|
|
||||||
|
expect(readFileSync(join(out, ".github", "ci.yml"), "utf-8")).toBe("on: push\n")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("overwrite false refuses a non-empty target and leaves it untouched", () => {
|
||||||
|
const render = initRenderer("./testdata/if_example")(ifExampleSchema)
|
||||||
|
const out = join(tmp, "out")
|
||||||
|
mkdirSync(out)
|
||||||
|
writeFileSync(join(out, "keep.txt"), "important")
|
||||||
|
|
||||||
|
expect(() => render(out, { web: true, header: { render: true, text: "T" } }, { overwrite: false })).toThrow(TargetExistsError)
|
||||||
|
expect(readFileSync(join(out, "keep.txt"), "utf-8")).toBe("important")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("overwrite false renders into a missing or empty target", () => {
|
||||||
|
const render = initRenderer("./testdata/if_example")(ifExampleSchema)
|
||||||
|
const missing = join(tmp, "missing")
|
||||||
|
const empty = join(tmp, "empty")
|
||||||
|
mkdirSync(empty)
|
||||||
|
|
||||||
|
render(missing, { web: true, header: { render: true, text: "T" } }, { overwrite: false })
|
||||||
|
render(empty, { web: true, header: { render: true, text: "T" } }, { overwrite: false })
|
||||||
|
|
||||||
|
expect(existsSync(join(missing, "web", "if_example.html"))).toBe(true)
|
||||||
|
expect(existsSync(join(empty, "web", "if_example.html"))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("overwrite false refuses when the target is a file", () => {
|
||||||
|
const render = initRenderer("./testdata/if_example")(ifExampleSchema)
|
||||||
|
const out = join(tmp, "out")
|
||||||
|
writeFileSync(out, "not a directory")
|
||||||
|
|
||||||
|
expect(() => render(out, { web: true, header: { render: true, text: "T" } }, { overwrite: false })).toThrow(TargetExistsError)
|
||||||
|
expect(readFileSync(out, "utf-8")).toBe("not a directory")
|
||||||
|
})
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ 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 { TargetExistsError } 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 {
|
||||||
|
|||||||
+6
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@gregorlohaus/tdir",
|
"name": "@gregorlohaus/tdir",
|
||||||
"version": "0.1.3",
|
"version": "0.3.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tdir": "./dist/cli.js"
|
"tdir": "dist/cli.js"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
@@ -25,5 +25,9 @@
|
|||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"zod": "^4"
|
"zod": "^4"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://gitea.gregorlohaus.com/gregor/tdir"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`
|
||||||
|
|||||||
@@ -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,27 +42,58 @@ 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[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RenderOptions = {
|
export type RenderOptions = {
|
||||||
reverseMap?: boolean | string
|
reverseMap?: boolean | string
|
||||||
|
/** When false, throw TargetExistsError instead of clearing a non-empty target. Defaults to true. */
|
||||||
|
overwrite?: boolean
|
||||||
|
/** Renames rendered file and directory names, e.g. `{ _gitignore: ".gitignore" }`. */
|
||||||
|
rename?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TargetExistsError extends Error {
|
||||||
|
constructor(target: string) {
|
||||||
|
super(`Refusing to overwrite non-empty target directory: ${target}`)
|
||||||
|
this.name = "TargetExistsError"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type RenderState = {
|
type RenderState = {
|
||||||
sourceRoot: string
|
sourceRoot: string
|
||||||
destRoot: string
|
destRoot: string
|
||||||
manifest?: ReverseMapManifest
|
manifest?: ReverseMapManifest
|
||||||
|
rename: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
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 +147,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,6 +162,17 @@ function addReverseMapToken(
|
|||||||
state.manifest.tokens[token.result] = tokens
|
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 {
|
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)
|
||||||
@@ -133,6 +181,7 @@ function getReverseMapPath(destRoot: string, reverseMap: true | string): string
|
|||||||
function getOutputName(
|
function getOutputName(
|
||||||
entry: string,
|
entry: string,
|
||||||
context: Record<string, unknown>,
|
context: Record<string, unknown>,
|
||||||
|
rename: Record<string, string>,
|
||||||
state?: RenderState,
|
state?: RenderState,
|
||||||
file?: ReverseMapFile,
|
file?: ReverseMapFile,
|
||||||
): string | null {
|
): string | null {
|
||||||
@@ -152,7 +201,7 @@ function getOutputName(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return outputName.replace(VAR_RE, (_match, path: string) => {
|
const rendered = outputName.replace(VAR_RE, (_match, path: string) => {
|
||||||
const result = String(resolveContext(context, path) ?? "")
|
const result = String(resolveContext(context, path) ?? "")
|
||||||
addReverseMapToken(state, file, {
|
addReverseMapToken(state, file, {
|
||||||
kind: "path",
|
kind: "path",
|
||||||
@@ -164,6 +213,15 @@ function getOutputName(
|
|||||||
})
|
})
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
return Object.hasOwn(rename, rendered) ? rename[rendered]! : rendered
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNonEmptyTarget(destRoot: string): boolean {
|
||||||
|
try {
|
||||||
|
return !statSync(destRoot).isDirectory() || readdirSync(destRoot).length > 0
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isUtf8Text(buffer: Buffer): boolean {
|
function isUtf8Text(buffer: Buffer): boolean {
|
||||||
@@ -176,70 +234,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 +412,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
|
||||||
|
|
||||||
@@ -289,10 +472,14 @@ function renderDir(
|
|||||||
sourceRoot,
|
sourceRoot,
|
||||||
destRoot,
|
destRoot,
|
||||||
manifest: options.reverseMap ? createReverseMapManifest() : undefined,
|
manifest: options.reverseMap ? createReverseMapManifest() : undefined,
|
||||||
|
rename: options.rename ?? {},
|
||||||
}
|
}
|
||||||
const mapPath = options.reverseMap ? getReverseMapPath(destRoot, options.reverseMap) : undefined
|
const mapPath = options.reverseMap ? getReverseMapPath(destRoot, options.reverseMap) : undefined
|
||||||
assertSafeRenderTarget(srcDir, destDir)
|
assertSafeRenderTarget(srcDir, destDir)
|
||||||
validateOutputPaths(srcDir, destRoot, context)
|
validateOutputPaths(srcDir, destRoot, context, state.rename)
|
||||||
|
if (options.overwrite === false && isNonEmptyTarget(destRoot)) {
|
||||||
|
throw new TargetExistsError(destDir)
|
||||||
|
}
|
||||||
rmSync(destDir, { recursive: true, force: true })
|
rmSync(destDir, { recursive: true, force: true })
|
||||||
renderDirInner(srcDir, destRoot, context, state)
|
renderDirInner(srcDir, destRoot, context, state)
|
||||||
if (state.manifest && mapPath) {
|
if (state.manifest && mapPath) {
|
||||||
@@ -305,22 +492,56 @@ function validateOutputPaths(
|
|||||||
srcDir: string,
|
srcDir: string,
|
||||||
destDir: string,
|
destDir: string,
|
||||||
context: Record<string, unknown>,
|
context: Record<string, unknown>,
|
||||||
|
rename: Record<string, string>,
|
||||||
) {
|
) {
|
||||||
const entries = readdirSync(srcDir).sort()
|
const entries = readdirSync(srcDir).sort()
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const srcPath = resolvePath(srcDir, entry)
|
const srcPath = resolvePath(srcDir, entry)
|
||||||
const stat = statSync(srcPath)
|
const stat = statSync(srcPath)
|
||||||
const outputName = getOutputName(entry, context)
|
const outputName = getOutputName(entry, context, rename)
|
||||||
if (outputName === null) continue
|
if (outputName === null) continue
|
||||||
|
|
||||||
const destPath = resolveOutputPath(destDir, outputName)
|
const destPath = resolveOutputPath(destDir, outputName)
|
||||||
if (stat.isDirectory()) {
|
if (stat.isDirectory()) {
|
||||||
validateOutputPaths(srcPath, destPath, context)
|
validateOutputPaths(srcPath, destPath, context, rename)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
@@ -339,8 +560,11 @@ function renderDirInner(
|
|||||||
templatePath,
|
templatePath,
|
||||||
tokens: [],
|
tokens: [],
|
||||||
}
|
}
|
||||||
const outputName = getOutputName(entry, context, state, tempFile)
|
const outputName = getOutputName(entry, context, state.rename, 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 +586,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+311
-3
@@ -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
@@ -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
|
||||||
|
}
|
||||||
Vendored
+3
@@ -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>
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
match
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
<@if(neq(context.test,"test"))>not-test<@else>test<@endif>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
not test
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
<@if(neq(context.project.frontend,"none"))>
|
||||||
|
bundev = {
|
||||||
|
exec = "bun dev";
|
||||||
|
cwd = "./apps/web";
|
||||||
|
after= ["devenv:processes:air@started"];
|
||||||
|
};
|
||||||
|
<@endif>
|
||||||
+1
-1
@@ -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