Compare commits
18 Commits
ca4a02ab4e
...
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e900729c8f | ||
|
|
fe3489e217 | ||
|
|
9ed1324e06 | ||
|
|
d8536d83de | ||
|
|
0fab9c8d38 | ||
|
|
e16fc8b482 | ||
|
|
7d01b2f7c9 | ||
|
|
0412cea241 | ||
|
|
0a512cdbc3 | ||
|
|
2971c87618 | ||
|
|
af0c25e64b | ||
|
|
8ad2545310 | ||
|
|
4d25d07687 | ||
|
|
b04b719840 | ||
|
|
bceed3711c | ||
|
|
28a2a771e8 | ||
|
|
bda6e8cc40 | ||
|
|
3110eefcbd |
39
.gitea/workflows/publish.yml
Normal file
39
.gitea/workflows/publish.yml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
name: Publish npm package
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: x86
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
registry-url: "https://registry.npmjs.org"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Check tag matches package version
|
||||||
|
run: |
|
||||||
|
PACKAGE_VERSION="v$(node -p "require('./package.json').version")"
|
||||||
|
TAG_NAME="${GITHUB_REF_NAME:-${GITHUB_REF#refs/tags/}}"
|
||||||
|
test "$PACKAGE_VERSION" = "$TAG_NAME"
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: bun run build
|
||||||
|
|
||||||
|
- name: Publish
|
||||||
|
run: npm publish --access public
|
||||||
|
env:
|
||||||
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -43,3 +43,4 @@ devenv.local.yaml
|
|||||||
|
|
||||||
# pre-commit
|
# pre-commit
|
||||||
.pre-commit-config.yaml
|
.pre-commit-config.yaml
|
||||||
|
.codex
|
||||||
|
|||||||
106
CLAUDE.md
106
CLAUDE.md
@@ -1,106 +0,0 @@
|
|||||||
|
|
||||||
Default to using Bun instead of Node.js.
|
|
||||||
|
|
||||||
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
|
|
||||||
- Use `bun test` instead of `jest` or `vitest`
|
|
||||||
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
|
|
||||||
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
|
|
||||||
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
|
|
||||||
- Use `bunx <package> <command>` instead of `npx <package> <command>`
|
|
||||||
- Bun automatically loads .env, so don't use dotenv.
|
|
||||||
|
|
||||||
## APIs
|
|
||||||
|
|
||||||
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
|
|
||||||
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
|
|
||||||
- `Bun.redis` for Redis. Don't use `ioredis`.
|
|
||||||
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
|
|
||||||
- `WebSocket` is built-in. Don't use `ws`.
|
|
||||||
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
|
|
||||||
- Bun.$`ls` instead of execa.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Use `bun test` to run tests.
|
|
||||||
|
|
||||||
```ts#index.test.ts
|
|
||||||
import { test, expect } from "bun:test";
|
|
||||||
|
|
||||||
test("hello world", () => {
|
|
||||||
expect(1).toBe(1);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Frontend
|
|
||||||
|
|
||||||
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
|
|
||||||
|
|
||||||
Server:
|
|
||||||
|
|
||||||
```ts#index.ts
|
|
||||||
import index from "./index.html"
|
|
||||||
|
|
||||||
Bun.serve({
|
|
||||||
routes: {
|
|
||||||
"/": index,
|
|
||||||
"/api/users/:id": {
|
|
||||||
GET: (req) => {
|
|
||||||
return new Response(JSON.stringify({ id: req.params.id }));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// optional websocket support
|
|
||||||
websocket: {
|
|
||||||
open: (ws) => {
|
|
||||||
ws.send("Hello, world!");
|
|
||||||
},
|
|
||||||
message: (ws, message) => {
|
|
||||||
ws.send(message);
|
|
||||||
},
|
|
||||||
close: (ws) => {
|
|
||||||
// handle close
|
|
||||||
}
|
|
||||||
},
|
|
||||||
development: {
|
|
||||||
hmr: true,
|
|
||||||
console: true,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
|
|
||||||
|
|
||||||
```html#index.html
|
|
||||||
<html>
|
|
||||||
<body>
|
|
||||||
<h1>Hello, world!</h1>
|
|
||||||
<script type="module" src="./frontend.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
```
|
|
||||||
|
|
||||||
With the following `frontend.tsx`:
|
|
||||||
|
|
||||||
```tsx#frontend.tsx
|
|
||||||
import React from "react";
|
|
||||||
import { createRoot } from "react-dom/client";
|
|
||||||
|
|
||||||
// import .css files directly and it works
|
|
||||||
import './index.css';
|
|
||||||
|
|
||||||
const root = createRoot(document.body);
|
|
||||||
|
|
||||||
export default function Frontend() {
|
|
||||||
return <h1>Hello, world!</h1>;
|
|
||||||
}
|
|
||||||
|
|
||||||
root.render(<Frontend />);
|
|
||||||
```
|
|
||||||
|
|
||||||
Then, run index.ts
|
|
||||||
|
|
||||||
```sh
|
|
||||||
bun --hot ./index.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.
|
|
||||||
134
README.md
134
README.md
@@ -1,6 +1,6 @@
|
|||||||
# tdir
|
# tdir
|
||||||
|
|
||||||
Treat a directory as a template. File paths and contents support conditionals (`@if`/`@elseif`/`@else`) and variable substitution (`@var`). Provide a Zod schema and tdir validates it matches the template at setup time, then validates context at render time.
|
Treat a directory as a template. File paths and text file contents support conditionals (`@if`/`@elseif`/`@else`) and variable substitution (`@var`). Provide a Zod schema and tdir validates it matches the template at setup time, then validates context at render time. Binary files are copied through unchanged.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ Where `index.html` contains:
|
|||||||
Render it:
|
Render it:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { initRenderer } from "tdir"
|
import { initRenderer } from "@gregorlohaus/tdir"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
|
||||||
const createRenderer = initRenderer("./templates")
|
const createRenderer = initRenderer("./templates")
|
||||||
@@ -58,8 +58,11 @@ render("./output", {
|
|||||||
|
|
||||||
| Directive | Description |
|
| Directive | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `<@if(context.x)>` | Conditional block — truthy 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 |
|
||||||
@@ -70,8 +73,11 @@ render("./output", {
|
|||||||
|
|
||||||
| Directive | Description |
|
| Directive | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `<@if(context.x)>dirname` | Conditionally include directory/file (truthy 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.
|
||||||
@@ -81,7 +87,7 @@ These can be combined: `<@if(context.web.create)><@var(context.web.dir)>` create
|
|||||||
`createRenderer` validates that your Zod schema matches the template variables. Mismatches throw `SchemaMismatchError`:
|
`createRenderer` validates that your Zod schema matches the template variables. Mismatches throw `SchemaMismatchError`:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { initRenderer, SchemaMismatchError } from "tdir"
|
import { initRenderer, SchemaMismatchError } from "@gregorlohaus/tdir"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
|
||||||
const createRenderer = initRenderer("./templates")
|
const createRenderer = initRenderer("./templates")
|
||||||
@@ -92,14 +98,14 @@ createRenderer(z.object({
|
|||||||
web: z.string(), // wrong type
|
web: z.string(), // wrong type
|
||||||
header: z.object({ show: z.boolean(), title: z.string() })
|
header: z.object({ show: z.boolean(), title: z.string() })
|
||||||
}))
|
}))
|
||||||
// SchemaMismatchError: Shema doesnt match used template variables: web: expected z.boolean() but schema has z.string()
|
// SchemaMismatchError: Schema doesn't match used template variables: web: expected z.boolean() but schema has z.string()
|
||||||
|
|
||||||
// Schema is missing fields used in templates -- throws SchemaMismatchError
|
// Schema is missing fields used in templates -- throws SchemaMismatchError
|
||||||
createRenderer(z.object({
|
createRenderer(z.object({
|
||||||
web: z.boolean()
|
web: z.boolean()
|
||||||
// missing header
|
// missing header
|
||||||
}))
|
}))
|
||||||
// SchemaMismatchError: Shema doesnt match used template variables: header: missing in schema
|
// SchemaMismatchError: Schema doesn't match used template variables: header: missing in schema
|
||||||
```
|
```
|
||||||
|
|
||||||
## Context validation
|
## Context validation
|
||||||
@@ -123,12 +129,122 @@ render("./output", { web: "not a boolean", header: { show: true, title: "Hi" } }
|
|||||||
|
|
||||||
`render(target, context)` clears `target` before writing, so rendering the same template into the same directory with different contexts always produces a clean result (files/paths excluded by conditionals won't linger from a previous run).
|
`render(target, context)` clears `target` before writing, so rendering the same template into the same directory with different contexts always produces a clean result (files/paths excluded by conditionals won't linger from a previous run).
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Reverse maps
|
||||||
|
|
||||||
|
Pass `{ reverseMap: true }` as the third render argument to write `.tdir-map.json` into the output directory:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
render("./output", {
|
||||||
|
web: true,
|
||||||
|
header: { show: true, title: "Hello" }
|
||||||
|
}, { reverseMap: true })
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass a string to choose a custom JSON path inside the output directory:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
render("./output", context, { reverseMap: "meta/reverse-map.json" })
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"outputPath": "web/index.html",
|
||||||
|
"templatePath": "<@if(context.web)>web/index.html",
|
||||||
|
"tokens": [
|
||||||
|
{
|
||||||
|
"kind": "content",
|
||||||
|
"result": "Hello",
|
||||||
|
"token": "<@var(context.header.title)>",
|
||||||
|
"contextPath": "header.title",
|
||||||
|
"outputPath": "web/index.html",
|
||||||
|
"templatePath": "<@if(context.web)>web/index.html",
|
||||||
|
"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": {
|
||||||
|
"Hello": ["<@var(context.header.title)>"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reverse CLI
|
||||||
|
|
||||||
|
Use the reverse map to rebuild template files from an edited rendered directory:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
tdir reverse ./output ./templates
|
||||||
|
```
|
||||||
|
|
||||||
|
Without installing the package first, run the published CLI through Bun:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bunx @gregorlohaus/tdir reverse ./output ./templates
|
||||||
|
```
|
||||||
|
|
||||||
|
By default, the command reads `./output/.tdir-map.json`. Use `--map` for a custom map path relative to the rendered directory:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
tdir reverse ./output ./templates --map meta/reverse-map.json
|
||||||
|
bunx @gregorlohaus/tdir reverse ./output ./templates --map meta/reverse-map.json
|
||||||
|
```
|
||||||
|
|
||||||
|
New files created in the rendered directory are ignored by default. Include them explicitly with one or more glob patterns:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
tdir reverse ./output ./templates --include "components/**"
|
||||||
|
tdir reverse ./output ./templates --include "components/**/*.ts" --include "pages/*.html"
|
||||||
|
```
|
||||||
|
|
||||||
|
Programmatically, pass the same globs to `reverseDir`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
reverseDir("./output", "./templates", {
|
||||||
|
include: ["components/**/*.ts", "pages/*.html"]
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
The command writes files at their original template paths, restores recorded `<@var(...)>` tokens, wraps edited inline conditional output back in the original conditional block, and restores template files that were skipped by path conditionals.
|
||||||
|
|
||||||
|
The 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
|
||||||
|
|
||||||
A `<@if>` without a matching `<@endif>` throws at render time:
|
A `<@if>` without a matching `<@endif>` throws when the renderer is initialized:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// If a template file contains <@if(context.x)> with no <@endif>
|
// If a template file contains <@if(context.x)> with no <@endif>
|
||||||
render("./output", { x: true })
|
initRenderer("./templates")
|
||||||
// Error: Unmatched <@if> without <@endif>
|
// Error: Unmatched <@if> without <@endif>
|
||||||
```
|
```
|
||||||
|
|||||||
8
bun.lock
8
bun.lock
@@ -3,15 +3,13 @@
|
|||||||
"configVersion": 1,
|
"configVersion": 1,
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "tdir",
|
"name": "@gregorlohaus/tdir",
|
||||||
"dependencies": {
|
|
||||||
"zod": "^4.3.6",
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.11",
|
"@types/bun": "^1.3.11",
|
||||||
|
"typescript": "^5",
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5",
|
"zod": "^4",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
88
cli.ts
Normal file
88
cli.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { reverseDir } from "./reverse"
|
||||||
|
|
||||||
|
function printHelp() {
|
||||||
|
console.log(`tdir
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
tdir reverse <rendered-dir> <template-dir> [--map <path>] [--include <glob>...]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
reverse Rebuild template files from a rendered directory and reverse map
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--map Reverse map path. Defaults to <rendered-dir>/.tdir-map.json.
|
||||||
|
Relative paths are resolved from <rendered-dir>.
|
||||||
|
--include Include new rendered files matching a glob. Can be repeated.
|
||||||
|
--help Show this help message.
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseReverseArgs(args: string[]) {
|
||||||
|
const positional: string[] = []
|
||||||
|
const include: string[] = []
|
||||||
|
let mapPath: string | undefined
|
||||||
|
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
const arg = args[i]!
|
||||||
|
if (arg === "--help" || arg === "-h") {
|
||||||
|
return { help: true, positional, mapPath, include }
|
||||||
|
}
|
||||||
|
if (arg === "--map") {
|
||||||
|
const value = args[++i]
|
||||||
|
if (!value) throw new Error("Missing value for --map")
|
||||||
|
mapPath = value
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (arg === "--include") {
|
||||||
|
const value = args[++i]
|
||||||
|
if (!value) throw new Error("Missing value for --include")
|
||||||
|
include.push(value)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
positional.push(arg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { help: false, positional, mapPath, include }
|
||||||
|
}
|
||||||
|
|
||||||
|
function main(argv: string[]) {
|
||||||
|
const [command, ...args] = argv
|
||||||
|
|
||||||
|
if (!command || command === "--help" || command === "-h") {
|
||||||
|
printHelp()
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command !== "reverse") {
|
||||||
|
throw new Error(`Unknown command: ${command}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseReverseArgs(args)
|
||||||
|
if (parsed.help) {
|
||||||
|
printHelp()
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const [renderedDir, templateDir] = parsed.positional
|
||||||
|
if (!renderedDir || !templateDir || parsed.positional.length > 2) {
|
||||||
|
throw new Error("Usage: tdir reverse <rendered-dir> <template-dir> [--map <path>] [--include <glob>...]")
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = reverseDir(renderedDir, templateDir, {
|
||||||
|
mapPath: parsed.mapPath,
|
||||||
|
include: parsed.include,
|
||||||
|
})
|
||||||
|
console.log(`Wrote ${result.filesWritten} file${result.filesWritten === 1 ? "" : "s"}`)
|
||||||
|
for (const warning of result.warnings) {
|
||||||
|
console.warn(`Warning: ${warning.outputPath}: ${warning.message}`)
|
||||||
|
}
|
||||||
|
return result.warnings.length > 0 ? 2 : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.exitCode = main(process.argv.slice(2))
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error instanceof Error ? error.message : String(error))
|
||||||
|
process.exitCode = 1
|
||||||
|
}
|
||||||
372
index.test.ts
372
index.test.ts
@@ -1,8 +1,8 @@
|
|||||||
import { expect, test, beforeEach, afterEach } from 'bun:test'
|
import { expect, test, beforeEach, afterEach } from 'bun:test'
|
||||||
import { initRenderer, SchemaMismatchError } from ".";
|
import { initRenderer, reverseDir, SchemaMismatchError } from "./index";
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs"
|
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"
|
||||||
import { join } from "node:path"
|
import { basename, join } from "node:path"
|
||||||
import { tmpdir } from "node:os"
|
import { tmpdir } from "node:os"
|
||||||
|
|
||||||
const ifExampleSchema = z.object({
|
const ifExampleSchema = z.object({
|
||||||
@@ -55,6 +55,212 @@ test("renders with web=true, header rendered", () => {
|
|||||||
expect(content).toContain("<body>")
|
expect(content).toContain("<body>")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("render can write a reverse map", () => {
|
||||||
|
const createRenderer = initRenderer("./testdata/if_example")
|
||||||
|
const render = createRenderer(ifExampleSchema)
|
||||||
|
render(tmp, { web: true, header: { render: true, text: "My Title" } }, { reverseMap: true })
|
||||||
|
|
||||||
|
const manifest = JSON.parse(readFileSync(join(tmp, ".tdir-map.json"), "utf-8"))
|
||||||
|
expect(manifest.version).toBe(1)
|
||||||
|
expect(manifest.tokens["My Title"]).toContain("<@var(context.header.text)>")
|
||||||
|
|
||||||
|
const file = manifest.files.find((entry: any) => entry.outputPath === join("web", "if_example.html"))
|
||||||
|
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({
|
||||||
|
kind: "content",
|
||||||
|
result: "My Title",
|
||||||
|
token: "<@var(context.header.text)>",
|
||||||
|
contextPath: "header.text",
|
||||||
|
outputPath: join("web", "if_example.html"),
|
||||||
|
templatePath: join("<@if(context.web)>web", "if_example.html"),
|
||||||
|
range: expect.any(Object),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reverse map records path variable tokens", () => {
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
render(tmp,{
|
||||||
|
web: {
|
||||||
|
create: true,
|
||||||
|
dir: "web"
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
render: false,
|
||||||
|
text: "test"
|
||||||
|
}
|
||||||
|
}, { reverseMap: "meta/reverse-map.json" })
|
||||||
|
|
||||||
|
const manifest = JSON.parse(readFileSync(join(tmp, "meta", "reverse-map.json"), "utf-8"))
|
||||||
|
expect(manifest.tokens["web"]).toContain("<@var(context.web.dir)>")
|
||||||
|
const pathFile = manifest.files.find((entry: any) => entry.outputPath === "web")
|
||||||
|
expect(pathFile.tokens).toContainEqual({
|
||||||
|
kind: "path",
|
||||||
|
result: "web",
|
||||||
|
token: "<@var(context.web.dir)>",
|
||||||
|
contextPath: "web.dir",
|
||||||
|
outputPath: "web",
|
||||||
|
templatePath: "<@if(context.web.create)><@var(context.web.dir)>",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reverseDir rebuilds templates from rendered output and map", () => {
|
||||||
|
const createRenderer = initRenderer("./testdata/if_example")
|
||||||
|
const render = createRenderer(ifExampleSchema)
|
||||||
|
const templateOut = join(tmp, "template")
|
||||||
|
const renderedOut = join(tmp, "rendered")
|
||||||
|
|
||||||
|
render(renderedOut, { web: true, header: { render: true, text: "My Title" } }, { reverseMap: true })
|
||||||
|
writeFileSync(join(renderedOut, "web", "if_example.html"), [
|
||||||
|
"<document>",
|
||||||
|
" <head> My Title </head>",
|
||||||
|
" <body>",
|
||||||
|
" <main>Edited rendered output</main>",
|
||||||
|
" </body>",
|
||||||
|
"</document>",
|
||||||
|
].join("\n"))
|
||||||
|
|
||||||
|
const result = reverseDir(renderedOut, templateOut)
|
||||||
|
expect(result.filesWritten).toBe(1)
|
||||||
|
expect(result.warnings).toEqual([])
|
||||||
|
|
||||||
|
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("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", () => {
|
||||||
|
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")
|
||||||
|
|
||||||
|
render(renderedOut,{
|
||||||
|
web: {
|
||||||
|
create: true,
|
||||||
|
dir: "web"
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
render: true,
|
||||||
|
text: "test"
|
||||||
|
}
|
||||||
|
}, { reverseMap: "meta/reverse-map.json" })
|
||||||
|
|
||||||
|
const result = reverseDir(renderedOut, templateOut, { mapPath: "meta/reverse-map.json" })
|
||||||
|
expect(result.filesWritten).toBe(1)
|
||||||
|
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({
|
||||||
@@ -245,13 +451,78 @@ test("file if",() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("no endif should throw",() => {
|
test("no endif should throw",() => {
|
||||||
const createRenderer = initRenderer("./testdata/no_end_if")
|
expect(() => initRenderer("./testdata/no_end_if")).toThrow("test.txt")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("path vars cannot write outside target",() => {
|
||||||
|
const createRenderer = initRenderer("./testdata/var_in_path")
|
||||||
|
const sentinel = join(tmp, "keep.txt")
|
||||||
|
const outsideName = `${basename(tmp)}-outside`
|
||||||
|
const outsidePath = join(tmp, "..", outsideName)
|
||||||
|
writeFileSync(sentinel, "keep")
|
||||||
const render = createRenderer(z.object({
|
const render = createRenderer(z.object({
|
||||||
test: z.boolean(),
|
web: z.object({
|
||||||
|
create: z.boolean(),
|
||||||
|
dir: z.string()
|
||||||
|
}),
|
||||||
|
header: z.object({
|
||||||
|
render: z.boolean(),
|
||||||
|
text: z.string()
|
||||||
|
})
|
||||||
}))
|
}))
|
||||||
expect( () => render(tmp,{
|
|
||||||
test: true,
|
expect(() => render(tmp,{
|
||||||
|
web: {
|
||||||
|
create: true,
|
||||||
|
dir: `../${outsideName}`
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
render: false,
|
||||||
|
text: "test"
|
||||||
|
}
|
||||||
})).toThrow()
|
})).toThrow()
|
||||||
|
expect(existsSync(outsidePath)).toBe(false)
|
||||||
|
expect(existsSync(sentinel)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("refuses overlapping source and target directories",() => {
|
||||||
|
const source = join(tmp, "template")
|
||||||
|
const target = join(source, "out")
|
||||||
|
mkdirSync(source, { recursive: true })
|
||||||
|
writeFileSync(join(source, "test.txt"), "test")
|
||||||
|
|
||||||
|
const createRenderer = initRenderer(source)
|
||||||
|
const render = createRenderer(z.object({}))
|
||||||
|
expect(() => render(target, {})).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("binary files are copied without text rendering",() => {
|
||||||
|
const source = join(tmp, "template")
|
||||||
|
const target = join(tmp, "out")
|
||||||
|
mkdirSync(source, { recursive: true })
|
||||||
|
const binary = Buffer.from([0, 255, 1, 2, 3])
|
||||||
|
writeFileSync(join(source, "asset.bin"), binary)
|
||||||
|
|
||||||
|
const createRenderer = initRenderer(source)
|
||||||
|
const render = createRenderer(z.object({}))
|
||||||
|
render(target, {})
|
||||||
|
|
||||||
|
expect(readFileSync(join(target, "asset.bin"))).toEqual(binary)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("conflicting template variable types should throw",() => {
|
||||||
|
const source = join(tmp, "template")
|
||||||
|
mkdirSync(source, { recursive: true })
|
||||||
|
writeFileSync(join(source, "test.txt"), [
|
||||||
|
"<@if(context.test)>",
|
||||||
|
"<@endif>",
|
||||||
|
"<@var(context.test)>"
|
||||||
|
].join("\n"))
|
||||||
|
|
||||||
|
const createRenderer = initRenderer(source)
|
||||||
|
expect(() => createRenderer(z.object({
|
||||||
|
test: z.boolean(),
|
||||||
|
}))).toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("if elseif else",() => {
|
test("if elseif else",() => {
|
||||||
@@ -297,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)
|
||||||
|
})
|
||||||
|
|||||||
59
index.ts
59
index.ts
@@ -1,6 +1,9 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { parse, type TemplateVariable } from "./parser"
|
import { parse, type TemplateVariable } from "./parser"
|
||||||
import { renderDir } from "./render"
|
import { renderDir, type RenderOptions } from "./render"
|
||||||
|
|
||||||
|
export type { RenderOptions, ReverseMapFile, ReverseMapManifest, ReverseMapStoredTemplate, ReverseMapToken } from "./render"
|
||||||
|
export { reverseDir, type ReverseOptions, type ReverseResult, type ReverseWarning } from "./reverse"
|
||||||
|
|
||||||
interface Stringable {
|
interface Stringable {
|
||||||
toString: () => string
|
toString: () => string
|
||||||
@@ -13,12 +16,37 @@ interface Issue {
|
|||||||
|
|
||||||
export class SchemaMismatchError extends Error {
|
export class SchemaMismatchError extends Error {
|
||||||
constructor(issue: Issue) {
|
constructor(issue: Issue) {
|
||||||
super(`Shema doesnt match used template variables: ${issue.path}: ${issue.message}` )
|
super(`Schema doesn't match used template variables: ${issue.path}: ${issue.message}` )
|
||||||
|
this.name = "SchemaMismatchError"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function zodTypeName(schema: z.ZodType): string {
|
||||||
|
if (schema instanceof z.ZodObject) return "object"
|
||||||
|
if (schema instanceof z.ZodString) return "string"
|
||||||
|
if (schema instanceof z.ZodBoolean) return "boolean"
|
||||||
|
if (schema instanceof z.ZodNumber) return "number"
|
||||||
|
return schema.constructor.name
|
||||||
|
}
|
||||||
|
|
||||||
|
function zodDisplayName(schema: z.ZodType): string {
|
||||||
|
const type = zodTypeName(schema)
|
||||||
|
return type.startsWith("Zod") ? type : `z.${type}()`
|
||||||
|
}
|
||||||
|
|
||||||
|
function setExpectedType(expected: Map<string, string>, path: string, type: string) {
|
||||||
|
const existing = expected.get(path)
|
||||||
|
if (existing && existing !== type) {
|
||||||
|
throw new SchemaMismatchError({
|
||||||
|
path,
|
||||||
|
message: `conflicting template variable types: expected both z.${existing}() and z.${type}()`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
expected.set(path, type)
|
||||||
|
}
|
||||||
|
|
||||||
function validateSchemaMatchesTemplates(userSchema: z.ZodType, variables: TemplateVariable[]) {
|
function validateSchemaMatchesTemplates(userSchema: z.ZodType, variables: TemplateVariable[]) {
|
||||||
if (userSchema._zod.def.type !== "object") {
|
if (!(userSchema instanceof z.ZodObject)) {
|
||||||
throw new SchemaMismatchError({ path: "", message: "Schema must be a z.object()" })
|
throw new SchemaMismatchError({ path: "", message: "Schema must be a z.object()" })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,31 +56,29 @@ function validateSchemaMatchesTemplates(userSchema: z.ZodType, variables: Templa
|
|||||||
const segments = v.path.split(".")
|
const segments = v.path.split(".")
|
||||||
for (let i = 0; i < segments.length - 1; i++) {
|
for (let i = 0; i < segments.length - 1; i++) {
|
||||||
const intermediate = segments.slice(0, i + 1).join(".")
|
const intermediate = segments.slice(0, i + 1).join(".")
|
||||||
if (!expected.has(intermediate)) {
|
setExpectedType(expected, intermediate, "object")
|
||||||
expected.set(intermediate, "object")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
expected.set(v.path, v.type)
|
setExpectedType(expected, v.path, v.type)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [path, expectedType] of expected) {
|
for (const [path, expectedType] of expected) {
|
||||||
const segments = path.split(".")
|
const segments = path.split(".")
|
||||||
let current = userSchema
|
let current: z.ZodType = userSchema
|
||||||
let currentPath = ""
|
let currentPath = ""
|
||||||
for (const seg of segments) {
|
for (const seg of segments) {
|
||||||
currentPath = currentPath ? `${currentPath}.${seg}` : seg
|
currentPath = currentPath ? `${currentPath}.${seg}` : seg
|
||||||
if (current._zod.def.type !== "object") {
|
if (!(current instanceof z.ZodObject)) {
|
||||||
throw new SchemaMismatchError({ path: currentPath, message: `expected z.object() but schema has z.${current._zod.def.type as string}()` })
|
throw new SchemaMismatchError({ path: currentPath, message: `expected z.object() but schema has ${zodDisplayName(current)}` })
|
||||||
}
|
}
|
||||||
const shape = (current as z.ZodObject<any>).shape
|
const shape = current.shape
|
||||||
if (!(seg in shape)) {
|
if (!(seg in shape)) {
|
||||||
throw new SchemaMismatchError({ path: currentPath, message: `missing in schema` })
|
throw new SchemaMismatchError({ path: currentPath, message: `missing in schema` })
|
||||||
}
|
}
|
||||||
current = shape[seg]
|
current = shape[seg]!
|
||||||
}
|
}
|
||||||
const actual = current._zod.def.type as string
|
const actual = zodTypeName(current)
|
||||||
if (actual !== expectedType) {
|
if (actual !== expectedType) {
|
||||||
throw new SchemaMismatchError({ path, message: `expected z.${expectedType}() but schema has z.${actual}()` })
|
throw new SchemaMismatchError({ path, message: `expected z.${expectedType}() but schema has ${zodDisplayName(current)}` })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,12 +88,11 @@ export const initRenderer = (dirPath: string) => {
|
|||||||
|
|
||||||
const createRenderer = <S extends z.ZodType>(userSchema: S) => {
|
const createRenderer = <S extends z.ZodType>(userSchema: S) => {
|
||||||
validateSchemaMatchesTemplates(userSchema, variables)
|
validateSchemaMatchesTemplates(userSchema, variables)
|
||||||
return (targetPath: string, context: z.infer<S>) => {
|
return (targetPath: string, context: z.infer<S>, options?: RenderOptions) => {
|
||||||
userSchema.parse(context)
|
userSchema.parse(context)
|
||||||
renderDir(dirPath, targetPath, context as Record<string, unknown>)
|
renderDir(dirPath, targetPath, context as Record<string, unknown>, options)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return createRenderer
|
return createRenderer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "@gregorlohaus/tdir",
|
"name": "@gregorlohaus/tdir",
|
||||||
"version": "0.1.2",
|
"version": "0.2.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": {
|
||||||
|
"tdir": "./dist/cli.js"
|
||||||
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"import": "./dist/index.js",
|
"import": "./dist/index.js",
|
||||||
@@ -13,7 +16,7 @@
|
|||||||
},
|
},
|
||||||
"files": ["dist"],
|
"files": ["dist"],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "bun build ./index.ts --outdir ./dist --target node --external zod && bunx tsc --project tsconfig.build.json",
|
"build": "bun build ./index.ts ./cli.ts --outdir ./dist --target node --external zod && bunx tsc --project tsconfig.build.json",
|
||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
98
parser.ts
98
parser.ts
@@ -1,56 +1,122 @@
|
|||||||
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 { 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 EQ_RE = /^eq\(context\.(.+?),\s*"(.*)"\)$/
|
const STRING_COMPARE_RE = /^(?:eq|neq)\(context\.(.+?),\s*"(.*)"\)$/
|
||||||
const PATH_RE = /^context\.(.+)$/
|
const PATH_RE = /^context\.(.+)$/
|
||||||
|
|
||||||
function extractCondition(expr: string, vars: TemplateVariable[]) {
|
function extractCondition(expr: string | undefined, vars: TemplateVariable[]) {
|
||||||
const eqMatch = expr.match(EQ_RE)
|
if (!expr) throw new Error("Missing condition expression")
|
||||||
if (eqMatch) {
|
for (const operator of ["and", "or"]) {
|
||||||
vars.push({ path: eqMatch[1]!, type: "string" })
|
const prefix = `${operator}(`
|
||||||
|
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)
|
||||||
if (pathMatch) {
|
if (pathMatch) {
|
||||||
vars.push({ path: pathMatch[1]!, type: "boolean" })
|
vars.push({ path: pathMatch[1]!, type: "boolean" })
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
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 walkDir(dirPath: string, vars: TemplateVariable[]) {
|
function validateIfBlocks(content: string, vars: TemplateVariable[], source: string) {
|
||||||
|
const stack: { sawElse: boolean }[] = []
|
||||||
|
|
||||||
|
for (const token of getDirectiveTokens(content)) {
|
||||||
|
const directive = token.type
|
||||||
|
const condition = token.condition
|
||||||
|
|
||||||
|
if (directive === "if") {
|
||||||
|
extractCondition(condition!, vars)
|
||||||
|
stack.push({ sawElse: false })
|
||||||
|
} else if (directive === "elseif") {
|
||||||
|
const frame = stack[stack.length - 1]
|
||||||
|
if (!frame) throw new Error(`${source}: Unexpected <@elseif> without <@if>`)
|
||||||
|
if (frame.sawElse) throw new Error(`${source}: Unexpected <@elseif> after <@else>`)
|
||||||
|
extractCondition(condition!, vars)
|
||||||
|
} else if (directive === "else") {
|
||||||
|
const frame = stack[stack.length - 1]
|
||||||
|
if (!frame) throw new Error(`${source}: Unexpected <@else> without <@if>`)
|
||||||
|
if (frame.sawElse) throw new Error(`${source}: Unexpected duplicate <@else>`)
|
||||||
|
frame.sawElse = true
|
||||||
|
} else if (directive === "endif") {
|
||||||
|
if (stack.length === 0) throw new Error(`${source}: Unexpected <@endif> without <@if>`)
|
||||||
|
stack.pop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stack.length > 0) {
|
||||||
|
throw new Error(`${source}: Unmatched <@if> without <@endif>`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUtf8Text(buffer: Buffer): boolean {
|
||||||
|
if (buffer.indexOf(0) !== -1) return false
|
||||||
|
try {
|
||||||
|
new TextDecoder("utf-8", { fatal: true }).decode(buffer)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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, "utf-8")
|
const content = readFileSync(fullPath)
|
||||||
extractFromString(content, vars)
|
if (isUtf8Text(content)) {
|
||||||
|
const text = content.toString("utf-8")
|
||||||
|
extractFromString(text, vars, relativePath || fullPath)
|
||||||
|
validateIfBlocks(text, vars, relativePath || fullPath)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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}`
|
||||||
|
|||||||
582
render.ts
582
render.ts
@@ -1,25 +1,96 @@
|
|||||||
import { readdirSync, statSync, readFileSync, mkdirSync, writeFileSync, rmSync } from "node:fs"
|
import {
|
||||||
import { join } from "node:path"
|
copyFileSync,
|
||||||
|
mkdirSync,
|
||||||
|
readFileSync,
|
||||||
|
readdirSync,
|
||||||
|
rmSync,
|
||||||
|
statSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs"
|
||||||
|
import { dirname, isAbsolute, relative, resolve as resolvePath } from "node:path"
|
||||||
|
import { homedir } from "node:os"
|
||||||
|
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\.(.+)$/
|
||||||
|
|
||||||
function evalCondition(expr: string, context: Record<string, unknown>): boolean {
|
export type ReverseMapToken = {
|
||||||
const eqMatch = expr.match(EQ_RE)
|
kind: "path" | "content" | "conditional"
|
||||||
if (eqMatch) {
|
result: string
|
||||||
return resolve(context, eqMatch[1]!) === eqMatch[2]
|
token: string
|
||||||
|
contextPath?: string
|
||||||
|
outputPath: string
|
||||||
|
templatePath: string
|
||||||
|
range?: {
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
}
|
||||||
|
activeRange?: {
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
}
|
||||||
|
before?: string
|
||||||
|
after?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReverseMapFile = {
|
||||||
|
outputPath: string
|
||||||
|
templatePath: string
|
||||||
|
tokens: ReverseMapToken[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReverseMapStoredTemplate = {
|
||||||
|
kind: "directory" | "file"
|
||||||
|
templatePath: string
|
||||||
|
encoding?: "utf8" | "base64"
|
||||||
|
content?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReverseMapManifest = {
|
||||||
|
version: 1
|
||||||
|
files: ReverseMapFile[]
|
||||||
|
skipped: ReverseMapStoredTemplate[]
|
||||||
|
tokens: Record<string, string[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RenderOptions = {
|
||||||
|
reverseMap?: boolean | string
|
||||||
|
}
|
||||||
|
|
||||||
|
type RenderState = {
|
||||||
|
sourceRoot: string
|
||||||
|
destRoot: string
|
||||||
|
manifest?: ReverseMapManifest
|
||||||
|
}
|
||||||
|
|
||||||
|
function evalCondition(expr: string | undefined, context: Record<string, unknown>): boolean {
|
||||||
|
if (!expr) throw new Error("Missing condition expression")
|
||||||
|
for (const operator of ["and", "or"] as const) {
|
||||||
|
const prefix = `${operator}(`
|
||||||
|
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) {
|
||||||
return !!resolve(context, pathMatch[1]!)
|
return !!resolveContext(context, pathMatch[1]!)
|
||||||
}
|
}
|
||||||
throw new Error(`Invalid condition expression: ${expr}`)
|
throw new Error(`Invalid condition expression: ${expr}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolve(context: Record<string, unknown>, path: string): unknown {
|
function resolveContext(context: Record<string, unknown>, path: string): unknown {
|
||||||
const segments = path.split(".")
|
const segments = path.split(".")
|
||||||
let current: unknown = context
|
let current: unknown = context
|
||||||
for (const seg of segments) {
|
for (const seg of segments) {
|
||||||
@@ -29,115 +100,466 @@ function resolve(context: Record<string, unknown>, path: string): unknown {
|
|||||||
return current
|
return current
|
||||||
}
|
}
|
||||||
|
|
||||||
// Each stack frame tracks: did any branch match yet, is the current branch active
|
function isInsidePath(parent: string, child: string): boolean {
|
||||||
type IfFrame = { matched: boolean; active: boolean }
|
const rel = relative(parent, child)
|
||||||
|
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))
|
||||||
|
}
|
||||||
|
|
||||||
function processIfBlocks(content: string, context: Record<string, unknown>): string {
|
function assertSafeRenderTarget(srcDir: string, destDir: string) {
|
||||||
let result = ""
|
const sourceRoot = resolvePath(srcDir)
|
||||||
let pos = 0
|
const destRoot = resolvePath(destDir)
|
||||||
const stack: IfFrame[] = []
|
|
||||||
|
|
||||||
const re = new RegExp(DIRECTIVE_RE.source, "g")
|
if (destRoot === resolvePath(destRoot, "..")) {
|
||||||
let match: RegExpExecArray | null
|
throw new Error("Refusing to render into filesystem root")
|
||||||
|
|
||||||
function isEmitting(): boolean {
|
|
||||||
return stack.every(f => f.active)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
while ((match = re.exec(content)) !== null) {
|
if (destRoot === resolvePath(process.cwd())) {
|
||||||
const directive = match[1]!
|
throw new Error("Refusing to render into the current working directory")
|
||||||
const condPath = match[2]
|
}
|
||||||
|
|
||||||
if (directive === "if") {
|
if (destRoot === resolvePath(homedir())) {
|
||||||
if (isEmitting()) result += content.slice(pos, match.index)
|
throw new Error("Refusing to render into the home directory")
|
||||||
const truthy = evalCondition(condPath!, context)
|
}
|
||||||
stack.push({ matched: truthy, active: truthy })
|
|
||||||
pos = re.lastIndex
|
if (isInsidePath(sourceRoot, destRoot) || isInsidePath(destRoot, sourceRoot)) {
|
||||||
} else if (directive === "elseif") {
|
throw new Error("Refusing to render when source and target directories overlap")
|
||||||
if (stack.length === 0) throw new Error("Unexpected <@elseif> without <@if>")
|
}
|
||||||
const top = stack[stack.length - 1]!
|
}
|
||||||
// Emit text before this directive if current branch was active
|
|
||||||
if (isEmitting()) result += content.slice(pos, match.index)
|
function resolveOutputPath(destRoot: string, outputName: string): string {
|
||||||
if (top.matched) {
|
const outputPath = resolvePath(destRoot, outputName)
|
||||||
// A previous branch already matched — skip this one
|
if (!isInsidePath(destRoot, outputPath)) {
|
||||||
top.active = false
|
throw new Error(`Refusing to write outside target directory: ${outputName}`)
|
||||||
} else {
|
}
|
||||||
const truthy = evalCondition(condPath!, context)
|
return outputPath
|
||||||
top.matched = truthy
|
}
|
||||||
top.active = truthy
|
|
||||||
}
|
function createReverseMapManifest(): ReverseMapManifest {
|
||||||
pos = re.lastIndex
|
return { version: 1, files: [], skipped: [], tokens: {} }
|
||||||
} else if (directive === "else") {
|
}
|
||||||
if (stack.length === 0) throw new Error("Unexpected <@else> without <@if>")
|
|
||||||
const top = stack[stack.length - 1]!
|
function addReverseMapToken(
|
||||||
if (isEmitting()) result += content.slice(pos, match.index)
|
state: RenderState | undefined,
|
||||||
top.active = !top.matched
|
file: ReverseMapFile | undefined,
|
||||||
top.matched = true
|
token: ReverseMapToken,
|
||||||
pos = re.lastIndex
|
) {
|
||||||
} else if (directive === "endif") {
|
if (!state?.manifest || !file) return
|
||||||
if (stack.length === 0) throw new Error("Unexpected <@endif> without <@if>")
|
file.tokens.push(token)
|
||||||
if (isEmitting()) result += content.slice(pos, match.index)
|
const tokens = state.manifest.tokens[token.result] ?? []
|
||||||
stack.pop()
|
if (!tokens.includes(token.token)) tokens.push(token.token)
|
||||||
pos = re.lastIndex
|
state.manifest.tokens[token.result] = tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFlatToken(
|
||||||
|
state: RenderState | undefined,
|
||||||
|
result: string,
|
||||||
|
token: string,
|
||||||
|
) {
|
||||||
|
if (!state?.manifest) return
|
||||||
|
const tokens = state.manifest.tokens[result] ?? []
|
||||||
|
if (!tokens.includes(token)) tokens.push(token)
|
||||||
|
state.manifest.tokens[result] = tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReverseMapPath(destRoot: string, reverseMap: true | string): string {
|
||||||
|
if (reverseMap === true) return resolveOutputPath(destRoot, ".tdir-map.json")
|
||||||
|
return resolveOutputPath(destRoot, reverseMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOutputName(
|
||||||
|
entry: string,
|
||||||
|
context: Record<string, unknown>,
|
||||||
|
state?: RenderState,
|
||||||
|
file?: ReverseMapFile,
|
||||||
|
): string | null {
|
||||||
|
const ifMatch = entry.match(IF_PATH_RE)
|
||||||
|
let outputName = entry
|
||||||
|
if (ifMatch) {
|
||||||
|
if (!evalCondition(ifMatch[1], context)) return null
|
||||||
|
outputName = ifMatch[2]!
|
||||||
|
if (ifMatch[0] !== outputName) {
|
||||||
|
addReverseMapToken(state, file, {
|
||||||
|
kind: "path",
|
||||||
|
result: outputName,
|
||||||
|
token: ifMatch[0],
|
||||||
|
outputPath: file?.outputPath ?? "",
|
||||||
|
templatePath: file?.templatePath ?? "",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stack.length > 0) {
|
return outputName.replace(VAR_RE, (_match, path: string) => {
|
||||||
throw new Error("Unmatched <@if> without <@endif>")
|
const result = String(resolveContext(context, path) ?? "")
|
||||||
|
addReverseMapToken(state, file, {
|
||||||
|
kind: "path",
|
||||||
|
result,
|
||||||
|
token: _match,
|
||||||
|
contextPath: path,
|
||||||
|
outputPath: file?.outputPath ?? "",
|
||||||
|
templatePath: file?.templatePath ?? "",
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUtf8Text(buffer: Buffer): boolean {
|
||||||
|
if (buffer.indexOf(0) !== -1) return false
|
||||||
|
try {
|
||||||
|
new TextDecoder("utf-8", { fatal: true }).decode(buffer)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type TextNode = {
|
||||||
|
type: "text"
|
||||||
|
text: 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 = ""
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.type === "text") {
|
||||||
|
result += node.text
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeBranch = getActiveBranch(node, context)
|
||||||
|
const start = outputStart + result.length
|
||||||
|
const renderedBranch = activeBranch
|
||||||
|
? renderNodes(activeBranch.nodes, context, conditionalTokens, start)
|
||||||
|
: ""
|
||||||
|
result += renderedBranch
|
||||||
|
conditionalTokens.push({
|
||||||
|
result: renderedBranch,
|
||||||
|
token: node.source,
|
||||||
|
range: { start, end: start + renderedBranch.length },
|
||||||
|
activeRange: activeBranch
|
||||||
|
? {
|
||||||
|
start: activeBranch.contentStart - node.sourceStart,
|
||||||
|
end: activeBranch.contentEnd - node.sourceStart,
|
||||||
|
}
|
||||||
|
: { start: node.source.length, end: node.source.length },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
const processed = processIfBlocks(content, context)
|
return renderContentWithMap(content, context)
|
||||||
return processed.replace(VAR_RE, (_match, path: string) => {
|
}
|
||||||
return String(resolve(context, path) ?? "")
|
|
||||||
})
|
function renderContentWithMap(
|
||||||
|
content: string,
|
||||||
|
context: Record<string, unknown>,
|
||||||
|
state?: RenderState,
|
||||||
|
file?: ReverseMapFile,
|
||||||
|
): string {
|
||||||
|
for (const match of content.matchAll(VAR_RE)) {
|
||||||
|
const token = match[0]
|
||||||
|
const path = match[1]!
|
||||||
|
addFlatToken(state, String(resolveContext(context, path) ?? ""), token)
|
||||||
|
}
|
||||||
|
|
||||||
|
const processedResult = processIfBlocksWithMap(content, context)
|
||||||
|
const processed = processedResult.content
|
||||||
|
for (const token of processedResult.conditionalTokens) {
|
||||||
|
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 pos = 0
|
||||||
|
|
||||||
|
for (const match of processed.matchAll(VAR_RE)) {
|
||||||
|
const token = match[0]
|
||||||
|
const path = match[1]!
|
||||||
|
const rendered = String(resolveContext(context, path) ?? "")
|
||||||
|
result += processed.slice(pos, match.index)
|
||||||
|
const start = result.length
|
||||||
|
result += rendered
|
||||||
|
addReverseMapToken(state, file, {
|
||||||
|
kind: "content",
|
||||||
|
result: rendered,
|
||||||
|
token,
|
||||||
|
contextPath: path,
|
||||||
|
outputPath: file?.outputPath ?? "",
|
||||||
|
templatePath: file?.templatePath ?? "",
|
||||||
|
range: { start, end: start + rendered.length },
|
||||||
|
})
|
||||||
|
pos = match.index! + token.length
|
||||||
|
}
|
||||||
|
|
||||||
|
result += processed.slice(pos)
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDir(
|
function renderDir(
|
||||||
srcDir: string,
|
srcDir: string,
|
||||||
destDir: string,
|
destDir: string,
|
||||||
context: Record<string, unknown>,
|
context: Record<string, unknown>,
|
||||||
|
options: RenderOptions = {},
|
||||||
) {
|
) {
|
||||||
|
const destRoot = resolvePath(destDir)
|
||||||
|
const sourceRoot = resolvePath(srcDir)
|
||||||
|
const state: RenderState = {
|
||||||
|
sourceRoot,
|
||||||
|
destRoot,
|
||||||
|
manifest: options.reverseMap ? createReverseMapManifest() : undefined,
|
||||||
|
}
|
||||||
|
const mapPath = options.reverseMap ? getReverseMapPath(destRoot, options.reverseMap) : undefined
|
||||||
|
assertSafeRenderTarget(srcDir, destDir)
|
||||||
|
validateOutputPaths(srcDir, destRoot, context)
|
||||||
rmSync(destDir, { recursive: true, force: true })
|
rmSync(destDir, { recursive: true, force: true })
|
||||||
renderDirInner(srcDir, destDir, context)
|
renderDirInner(srcDir, destRoot, context, state)
|
||||||
|
if (state.manifest && mapPath) {
|
||||||
|
mkdirSync(dirname(mapPath), { recursive: true })
|
||||||
|
writeFileSync(mapPath, `${JSON.stringify(state.manifest, null, 2)}\n`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateOutputPaths(
|
||||||
|
srcDir: string,
|
||||||
|
destDir: string,
|
||||||
|
context: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
const entries = readdirSync(srcDir).sort()
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const srcPath = resolvePath(srcDir, entry)
|
||||||
|
const stat = statSync(srcPath)
|
||||||
|
const outputName = getOutputName(entry, context)
|
||||||
|
if (outputName === null) continue
|
||||||
|
|
||||||
|
const destPath = resolveOutputPath(destDir, outputName)
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
validateOutputPaths(srcPath, destPath, context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
context: Record<string, unknown>,
|
context: Record<string, unknown>,
|
||||||
|
state: RenderState,
|
||||||
) {
|
) {
|
||||||
mkdirSync(destDir, { recursive: true })
|
mkdirSync(destDir, { recursive: true })
|
||||||
const entries = readdirSync(srcDir).sort()
|
const entries = readdirSync(srcDir).sort()
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const srcPath = join(srcDir, entry)
|
const srcPath = resolvePath(srcDir, entry)
|
||||||
const stat = statSync(srcPath)
|
const stat = statSync(srcPath)
|
||||||
|
const templatePath = relative(state.sourceRoot, srcPath)
|
||||||
// Check if path segment has an @if directive
|
const tempFile: ReverseMapFile = {
|
||||||
const ifMatch = entry.match(IF_PATH_RE)
|
outputPath: "",
|
||||||
let outputName = entry
|
templatePath,
|
||||||
if (ifMatch) {
|
tokens: [],
|
||||||
if (!evalCondition(ifMatch[1]!, context)) continue
|
}
|
||||||
outputName = ifMatch[2]!
|
const outputName = getOutputName(entry, context, state, tempFile)
|
||||||
|
if (outputName === null) {
|
||||||
|
storeSkippedTemplate(srcPath, state)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process @var in the output name
|
const destPath = resolveOutputPath(destDir, outputName)
|
||||||
outputName = outputName.replace(VAR_RE, (_match, path: string) => {
|
const outputPath = relative(state.destRoot, destPath)
|
||||||
return String(resolve(context, path) ?? "")
|
tempFile.outputPath = outputPath
|
||||||
})
|
for (const token of tempFile.tokens) {
|
||||||
|
token.outputPath = outputPath
|
||||||
|
token.templatePath = templatePath
|
||||||
|
}
|
||||||
|
|
||||||
const destPath = join(destDir, outputName)
|
|
||||||
if (stat.isDirectory()) {
|
if (stat.isDirectory()) {
|
||||||
renderDirInner(srcPath, destPath, context)
|
if (tempFile.tokens.length > 0) state.manifest?.files.push(tempFile)
|
||||||
|
renderDirInner(srcPath, destPath, context, state)
|
||||||
} else {
|
} else {
|
||||||
mkdirSync(destDir, { recursive: true })
|
mkdirSync(dirname(destPath), { recursive: true })
|
||||||
const content = readFileSync(srcPath, "utf-8")
|
const content = readFileSync(srcPath)
|
||||||
writeFileSync(destPath, renderContent(content, context))
|
if (isUtf8Text(content)) {
|
||||||
|
const rendered = renderContentWithMap(content.toString("utf-8"), context, state, tempFile)
|
||||||
|
writeFileSync(destPath, rendered)
|
||||||
|
} else {
|
||||||
|
copyFileSync(srcPath, destPath)
|
||||||
|
}
|
||||||
|
if (state.manifest) state.manifest.files.push(tempFile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
466
reverse.ts
Normal file
466
reverse.ts
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
import {
|
||||||
|
copyFileSync,
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
readFileSync,
|
||||||
|
readdirSync,
|
||||||
|
statSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs"
|
||||||
|
import { dirname, isAbsolute, relative, resolve as resolvePath } from "node:path"
|
||||||
|
import { TextDecoder } from "node:util"
|
||||||
|
import type { ReverseMapFile, ReverseMapManifest, ReverseMapStoredTemplate, ReverseMapToken } from "./render"
|
||||||
|
|
||||||
|
export type ReverseOptions = {
|
||||||
|
mapPath?: string
|
||||||
|
include?: string | string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReverseWarning = {
|
||||||
|
outputPath: string
|
||||||
|
token: string
|
||||||
|
result: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReverseResult = {
|
||||||
|
filesWritten: number
|
||||||
|
warnings: ReverseWarning[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInsidePath(parent: string, child: string): boolean {
|
||||||
|
const rel = relative(parent, child)
|
||||||
|
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveInside(root: string, path: string): string {
|
||||||
|
const resolved = resolvePath(root, path)
|
||||||
|
if (!isInsidePath(root, resolved)) {
|
||||||
|
throw new Error(`Refusing to write outside target directory: ${path}`)
|
||||||
|
}
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUtf8Text(buffer: Buffer): boolean {
|
||||||
|
if (buffer.indexOf(0) !== -1) return false
|
||||||
|
try {
|
||||||
|
new TextDecoder("utf-8", { fatal: true }).decode(buffer)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readManifest(mapPath: string): ReverseMapManifest {
|
||||||
|
const manifest = JSON.parse(readFileSync(mapPath, "utf-8")) as ReverseMapManifest
|
||||||
|
if (manifest.version !== 1 || !Array.isArray(manifest.files)) {
|
||||||
|
throw new Error(`Unsupported reverse map: ${mapPath}`)
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
if (!token.range) return null
|
||||||
|
const { start, end } = token.range
|
||||||
|
if (start < 0 || end < start || end > content.length) return null
|
||||||
|
if (content.slice(start, end) !== token.result) return null
|
||||||
|
return `${content.slice(0, start)}${token.token}${content.slice(end)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceFirst(content: string, token: ReverseMapToken): string | null {
|
||||||
|
const index = content.indexOf(token.result)
|
||||||
|
if (index === -1) return null
|
||||||
|
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(
|
||||||
|
content: string,
|
||||||
|
file: ReverseMapFile,
|
||||||
|
warnings: ReverseWarning[],
|
||||||
|
): string {
|
||||||
|
const contentTokens = file.tokens
|
||||||
|
.filter(token => token.kind === "content")
|
||||||
|
.sort((a, b) => (b.range?.start ?? -1) - (a.range?.start ?? -1))
|
||||||
|
|
||||||
|
let reversed = content
|
||||||
|
for (const token of contentTokens) {
|
||||||
|
const rangeResult = replaceAtRange(reversed, token)
|
||||||
|
if (rangeResult !== null) {
|
||||||
|
reversed = rangeResult
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackResult = replaceFirst(reversed, token)
|
||||||
|
if (fallbackResult !== null) {
|
||||||
|
reversed = fallbackResult
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
warnings.push({
|
||||||
|
outputPath: file.outputPath,
|
||||||
|
token: token.token,
|
||||||
|
result: token.result,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
renderedDir: string,
|
||||||
|
templateDir: string,
|
||||||
|
options: ReverseOptions = {},
|
||||||
|
): ReverseResult {
|
||||||
|
const renderedRoot = resolvePath(renderedDir)
|
||||||
|
const templateRoot = resolvePath(templateDir)
|
||||||
|
const mapPath = options.mapPath
|
||||||
|
? resolvePath(renderedRoot, options.mapPath)
|
||||||
|
: resolvePath(renderedRoot, ".tdir-map.json")
|
||||||
|
const manifest = readManifest(mapPath)
|
||||||
|
const directoryMap = buildDirectoryMap(manifest)
|
||||||
|
const includedOutputPaths = getIncludedRenderedFiles(
|
||||||
|
renderedRoot,
|
||||||
|
templateRoot,
|
||||||
|
mapPath,
|
||||||
|
manifest,
|
||||||
|
options.include,
|
||||||
|
)
|
||||||
|
const warnings: ReverseWarning[] = []
|
||||||
|
let filesWritten = 0
|
||||||
|
|
||||||
|
for (const file of manifest.files) {
|
||||||
|
const renderedPath = resolveInside(renderedRoot, file.outputPath)
|
||||||
|
const templatePath = resolveInside(templateRoot, file.templatePath)
|
||||||
|
|
||||||
|
if (!existsSync(renderedPath)) {
|
||||||
|
warnings.push({
|
||||||
|
outputPath: file.outputPath,
|
||||||
|
token: "",
|
||||||
|
result: "",
|
||||||
|
message: "Rendered path does not exist; skipped",
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = statSync(renderedPath)
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
mkdirSync(templatePath, { recursive: true })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!stat.isFile()) continue
|
||||||
|
|
||||||
|
mkdirSync(dirname(templatePath), { recursive: true })
|
||||||
|
const content = readFileSync(renderedPath)
|
||||||
|
if (!isUtf8Text(content)) {
|
||||||
|
copyFileSync(renderedPath, templatePath)
|
||||||
|
filesWritten += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
templatePath,
|
||||||
|
reverseContent(content.toString("utf-8"), file, warnings),
|
||||||
|
)
|
||||||
|
filesWritten += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const skipped of manifest.skipped ?? []) {
|
||||||
|
filesWritten += writeSkippedTemplate(templateRoot, skipped)
|
||||||
|
}
|
||||||
|
|
||||||
|
filesWritten += copyIncludedRenderedFiles(
|
||||||
|
renderedRoot,
|
||||||
|
templateRoot,
|
||||||
|
includedOutputPaths,
|
||||||
|
directoryMap,
|
||||||
|
manifest,
|
||||||
|
)
|
||||||
|
|
||||||
|
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"]
|
"include": ["index.ts", "parser.ts", "render.ts", "reverse.ts", "scanner.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user