hardening
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -43,3 +43,4 @@ devenv.local.yaml
|
||||
|
||||
# pre-commit
|
||||
.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`.
|
||||
20
README.md
20
README.md
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
|
||||
@@ -32,7 +32,7 @@ Where `index.html` contains:
|
||||
Render it:
|
||||
|
||||
```ts
|
||||
import { initRenderer } from "tdir"
|
||||
import { initRenderer } from "@gregorlohaus/tdir"
|
||||
import { z } from "zod"
|
||||
|
||||
const createRenderer = initRenderer("./templates")
|
||||
@@ -58,7 +58,7 @@ render("./output", {
|
||||
|
||||
| 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 |
|
||||
| `<@elseif(context.y)>` | Else-if branch (same forms as `@if`) |
|
||||
| `<@else>` | Else branch |
|
||||
@@ -70,7 +70,7 @@ render("./output", {
|
||||
|
||||
| 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 |
|
||||
| `<@var(context.x)>` | Dynamic directory/file name |
|
||||
|
||||
@@ -81,7 +81,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`:
|
||||
|
||||
```ts
|
||||
import { initRenderer, SchemaMismatchError } from "tdir"
|
||||
import { initRenderer, SchemaMismatchError } from "@gregorlohaus/tdir"
|
||||
import { z } from "zod"
|
||||
|
||||
const createRenderer = initRenderer("./templates")
|
||||
@@ -92,14 +92,14 @@ createRenderer(z.object({
|
||||
web: z.string(), // wrong type
|
||||
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
|
||||
createRenderer(z.object({
|
||||
web: z.boolean()
|
||||
// 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
|
||||
@@ -123,12 +123,14 @@ 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).
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
// If a template file contains <@if(context.x)> with no <@endif>
|
||||
render("./output", { x: true })
|
||||
initRenderer("./templates")
|
||||
// Error: Unmatched <@if> without <@endif>
|
||||
```
|
||||
|
||||
8
bun.lock
8
bun.lock
@@ -3,15 +3,13 @@
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "tdir",
|
||||
"dependencies": {
|
||||
"zod": "^4.3.6",
|
||||
},
|
||||
"name": "@gregorlohaus/tdir",
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.11",
|
||||
"typescript": "^5",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
"zod": "^4",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { expect, test, beforeEach, afterEach } from 'bun:test'
|
||||
import { initRenderer, SchemaMismatchError } from ".";
|
||||
import { initRenderer, SchemaMismatchError } from "./index";
|
||||
import { z } from "zod"
|
||||
import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"
|
||||
import { basename, join } from "node:path"
|
||||
import { tmpdir } from "node:os"
|
||||
|
||||
const ifExampleSchema = z.object({
|
||||
@@ -245,13 +245,78 @@ test("file if",() => {
|
||||
})
|
||||
|
||||
test("no endif should throw",() => {
|
||||
const createRenderer = initRenderer("./testdata/no_end_if")
|
||||
expect(() => initRenderer("./testdata/no_end_if")).toThrow()
|
||||
})
|
||||
|
||||
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({
|
||||
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,
|
||||
web: {
|
||||
create: true,
|
||||
dir: `../${outsideName}`
|
||||
},
|
||||
header: {
|
||||
render: false,
|
||||
text: "test"
|
||||
}
|
||||
})).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",() => {
|
||||
|
||||
50
index.ts
50
index.ts
@@ -13,12 +13,37 @@ interface Issue {
|
||||
|
||||
export class SchemaMismatchError extends Error {
|
||||
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[]) {
|
||||
if (userSchema._zod.def.type !== "object") {
|
||||
if (!(userSchema instanceof z.ZodObject)) {
|
||||
throw new SchemaMismatchError({ path: "", message: "Schema must be a z.object()" })
|
||||
}
|
||||
|
||||
@@ -28,31 +53,29 @@ function validateSchemaMatchesTemplates(userSchema: z.ZodType, variables: Templa
|
||||
const segments = v.path.split(".")
|
||||
for (let i = 0; i < segments.length - 1; i++) {
|
||||
const intermediate = segments.slice(0, i + 1).join(".")
|
||||
if (!expected.has(intermediate)) {
|
||||
expected.set(intermediate, "object")
|
||||
setExpectedType(expected, intermediate, "object")
|
||||
}
|
||||
}
|
||||
expected.set(v.path, v.type)
|
||||
setExpectedType(expected, v.path, v.type)
|
||||
}
|
||||
|
||||
for (const [path, expectedType] of expected) {
|
||||
const segments = path.split(".")
|
||||
let current = userSchema
|
||||
let current: z.ZodType = userSchema
|
||||
let currentPath = ""
|
||||
for (const seg of segments) {
|
||||
currentPath = currentPath ? `${currentPath}.${seg}` : seg
|
||||
if (current._zod.def.type !== "object") {
|
||||
throw new SchemaMismatchError({ path: currentPath, message: `expected z.object() but schema has z.${current._zod.def.type as string}()` })
|
||||
if (!(current instanceof z.ZodObject)) {
|
||||
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)) {
|
||||
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) {
|
||||
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)}` })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,4 +93,3 @@ export const initRenderer = (dirPath: string) => {
|
||||
|
||||
return createRenderer
|
||||
}
|
||||
|
||||
|
||||
56
parser.ts
56
parser.ts
@@ -1,5 +1,6 @@
|
||||
import { readdirSync, statSync, readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { TextDecoder } from "node:util"
|
||||
|
||||
export type TemplateVariable = {
|
||||
path: string
|
||||
@@ -8,10 +9,12 @@ export type TemplateVariable = {
|
||||
|
||||
const IF_RE = /<@(?:if|elseif)\((.+?)\)>/g
|
||||
const VAR_RE = /<@var\(context\.(.+?)(?::(\w+))?\)>/g
|
||||
const DIRECTIVE_RE = /<@(if|elseif|else|endif)(?:\((.+?)\))?>/g
|
||||
const EQ_RE = /^eq\(context\.(.+?),\s*"(.*)"\)$/
|
||||
const PATH_RE = /^context\.(.+)$/
|
||||
|
||||
function extractCondition(expr: string, vars: TemplateVariable[]) {
|
||||
function extractCondition(expr: string | undefined, vars: TemplateVariable[]) {
|
||||
if (!expr) throw new Error("Missing condition expression")
|
||||
const eqMatch = expr.match(EQ_RE)
|
||||
if (eqMatch) {
|
||||
vars.push({ path: eqMatch[1]!, type: "string" })
|
||||
@@ -20,7 +23,9 @@ function extractCondition(expr: string, vars: TemplateVariable[]) {
|
||||
const pathMatch = expr.match(PATH_RE)
|
||||
if (pathMatch) {
|
||||
vars.push({ path: pathMatch[1]!, type: "boolean" })
|
||||
return
|
||||
}
|
||||
throw new Error(`Invalid condition expression: ${expr}`)
|
||||
}
|
||||
|
||||
function extractFromString(text: string, vars: TemplateVariable[]) {
|
||||
@@ -32,6 +37,47 @@ function extractFromString(text: string, vars: TemplateVariable[]) {
|
||||
}
|
||||
}
|
||||
|
||||
function validateIfBlocks(content: string, vars: TemplateVariable[]) {
|
||||
const stack: { sawElse: boolean }[] = []
|
||||
|
||||
for (const match of content.matchAll(DIRECTIVE_RE)) {
|
||||
const directive = match[1]!
|
||||
const condition = match[2]
|
||||
|
||||
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("Unexpected <@elseif> without <@if>")
|
||||
if (frame.sawElse) throw new Error("Unexpected <@elseif> after <@else>")
|
||||
extractCondition(condition!, vars)
|
||||
} else if (directive === "else") {
|
||||
const frame = stack[stack.length - 1]
|
||||
if (!frame) throw new Error("Unexpected <@else> without <@if>")
|
||||
if (frame.sawElse) throw new Error("Unexpected duplicate <@else>")
|
||||
frame.sawElse = true
|
||||
} else if (directive === "endif") {
|
||||
if (stack.length === 0) throw new Error("Unexpected <@endif> without <@if>")
|
||||
stack.pop()
|
||||
}
|
||||
}
|
||||
|
||||
if (stack.length > 0) {
|
||||
throw new Error("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[]) {
|
||||
const entries = readdirSync(dirPath).sort()
|
||||
for (const entry of entries) {
|
||||
@@ -42,8 +88,12 @@ function walkDir(dirPath: string, vars: TemplateVariable[]) {
|
||||
if (stat.isDirectory()) {
|
||||
walkDir(fullPath, vars)
|
||||
} else if (stat.isFile()) {
|
||||
const content = readFileSync(fullPath, "utf-8")
|
||||
extractFromString(content, vars)
|
||||
const content = readFileSync(fullPath)
|
||||
if (isUtf8Text(content)) {
|
||||
const text = content.toString("utf-8")
|
||||
extractFromString(text, vars)
|
||||
validateIfBlocks(text, vars)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
146
render.ts
146
render.ts
@@ -1,5 +1,15 @@
|
||||
import { readdirSync, statSync, readFileSync, mkdirSync, writeFileSync, rmSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import {
|
||||
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"
|
||||
|
||||
const IF_PATH_RE = /^<@if\((.+?)\)>(.*)$/
|
||||
const VAR_RE = /<@var\(context\.(.+?)(?::(\w+))?\)>/g
|
||||
@@ -7,19 +17,20 @@ const DIRECTIVE_RE = /<@(if|elseif|else|endif)(?:\((.+?)\))?>/g
|
||||
const EQ_RE = /^eq\(context\.(.+?),\s*"(.*)"\)$/
|
||||
const PATH_RE = /^context\.(.+)$/
|
||||
|
||||
function evalCondition(expr: string, context: Record<string, unknown>): boolean {
|
||||
function evalCondition(expr: string | undefined, context: Record<string, unknown>): boolean {
|
||||
if (!expr) throw new Error("Missing condition expression")
|
||||
const eqMatch = expr.match(EQ_RE)
|
||||
if (eqMatch) {
|
||||
return resolve(context, eqMatch[1]!) === eqMatch[2]
|
||||
return resolveContext(context, eqMatch[1]!) === eqMatch[2]
|
||||
}
|
||||
const pathMatch = expr.match(PATH_RE)
|
||||
if (pathMatch) {
|
||||
return !!resolve(context, pathMatch[1]!)
|
||||
return !!resolveContext(context, pathMatch[1]!)
|
||||
}
|
||||
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(".")
|
||||
let current: unknown = context
|
||||
for (const seg of segments) {
|
||||
@@ -29,8 +40,65 @@ function resolve(context: Record<string, unknown>, path: string): unknown {
|
||||
return current
|
||||
}
|
||||
|
||||
function isInsidePath(parent: string, child: string): boolean {
|
||||
const rel = relative(parent, child)
|
||||
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))
|
||||
}
|
||||
|
||||
function assertSafeRenderTarget(srcDir: string, destDir: string) {
|
||||
const sourceRoot = resolvePath(srcDir)
|
||||
const destRoot = resolvePath(destDir)
|
||||
|
||||
if (destRoot === resolvePath(destRoot, "..")) {
|
||||
throw new Error("Refusing to render into filesystem root")
|
||||
}
|
||||
|
||||
if (destRoot === resolvePath(process.cwd())) {
|
||||
throw new Error("Refusing to render into the current working directory")
|
||||
}
|
||||
|
||||
if (destRoot === resolvePath(homedir())) {
|
||||
throw new Error("Refusing to render into the home directory")
|
||||
}
|
||||
|
||||
if (isInsidePath(sourceRoot, destRoot) || isInsidePath(destRoot, sourceRoot)) {
|
||||
throw new Error("Refusing to render when source and target directories overlap")
|
||||
}
|
||||
}
|
||||
|
||||
function resolveOutputPath(destRoot: string, outputName: string): string {
|
||||
const outputPath = resolvePath(destRoot, outputName)
|
||||
if (!isInsidePath(destRoot, outputPath)) {
|
||||
throw new Error(`Refusing to write outside target directory: ${outputName}`)
|
||||
}
|
||||
return outputPath
|
||||
}
|
||||
|
||||
function getOutputName(entry: string, context: Record<string, unknown>): string | null {
|
||||
const ifMatch = entry.match(IF_PATH_RE)
|
||||
let outputName = entry
|
||||
if (ifMatch) {
|
||||
if (!evalCondition(ifMatch[1], context)) return null
|
||||
outputName = ifMatch[2]!
|
||||
}
|
||||
|
||||
return outputName.replace(VAR_RE, (_match, path: string) => {
|
||||
return String(resolveContext(context, path) ?? "")
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Each stack frame tracks: did any branch match yet, is the current branch active
|
||||
type IfFrame = { matched: boolean; active: boolean }
|
||||
type IfFrame = { matched: boolean; active: boolean; sawElse: boolean }
|
||||
|
||||
function processIfBlocks(content: string, context: Record<string, unknown>): string {
|
||||
let result = ""
|
||||
@@ -50,19 +118,20 @@ function processIfBlocks(content: string, context: Record<string, unknown>): str
|
||||
|
||||
if (directive === "if") {
|
||||
if (isEmitting()) result += content.slice(pos, match.index)
|
||||
const truthy = evalCondition(condPath!, context)
|
||||
stack.push({ matched: truthy, active: truthy })
|
||||
const truthy = evalCondition(condPath, context)
|
||||
stack.push({ matched: truthy, active: truthy, sawElse: false })
|
||||
pos = re.lastIndex
|
||||
} else if (directive === "elseif") {
|
||||
if (stack.length === 0) throw new Error("Unexpected <@elseif> without <@if>")
|
||||
const top = stack[stack.length - 1]!
|
||||
if (top.sawElse) throw new Error("Unexpected <@elseif> after <@else>")
|
||||
// Emit text before this directive if current branch was active
|
||||
if (isEmitting()) result += content.slice(pos, match.index)
|
||||
if (top.matched) {
|
||||
// A previous branch already matched — skip this one
|
||||
top.active = false
|
||||
} else {
|
||||
const truthy = evalCondition(condPath!, context)
|
||||
const truthy = evalCondition(condPath, context)
|
||||
top.matched = truthy
|
||||
top.active = truthy
|
||||
}
|
||||
@@ -70,9 +139,11 @@ function processIfBlocks(content: string, context: Record<string, unknown>): str
|
||||
} else if (directive === "else") {
|
||||
if (stack.length === 0) throw new Error("Unexpected <@else> without <@if>")
|
||||
const top = stack[stack.length - 1]!
|
||||
if (top.sawElse) throw new Error("Unexpected duplicate <@else>")
|
||||
if (isEmitting()) result += content.slice(pos, match.index)
|
||||
top.active = !top.matched
|
||||
top.matched = true
|
||||
top.sawElse = true
|
||||
pos = re.lastIndex
|
||||
} else if (directive === "endif") {
|
||||
if (stack.length === 0) throw new Error("Unexpected <@endif> without <@if>")
|
||||
@@ -93,7 +164,7 @@ function processIfBlocks(content: string, context: Record<string, unknown>): str
|
||||
function renderContent(content: string, context: Record<string, unknown>): string {
|
||||
const processed = processIfBlocks(content, context)
|
||||
return processed.replace(VAR_RE, (_match, path: string) => {
|
||||
return String(resolve(context, path) ?? "")
|
||||
return String(resolveContext(context, path) ?? "")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -102,8 +173,31 @@ function renderDir(
|
||||
destDir: string,
|
||||
context: Record<string, unknown>,
|
||||
) {
|
||||
const destRoot = resolvePath(destDir)
|
||||
assertSafeRenderTarget(srcDir, destDir)
|
||||
validateOutputPaths(srcDir, destRoot, context)
|
||||
rmSync(destDir, { recursive: true, force: true })
|
||||
renderDirInner(srcDir, destDir, context)
|
||||
renderDirInner(srcDir, destRoot, context)
|
||||
}
|
||||
|
||||
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 renderDirInner(
|
||||
@@ -115,29 +209,23 @@ function renderDirInner(
|
||||
const entries = readdirSync(srcDir).sort()
|
||||
|
||||
for (const entry of entries) {
|
||||
const srcPath = join(srcDir, entry)
|
||||
const srcPath = resolvePath(srcDir, entry)
|
||||
const stat = statSync(srcPath)
|
||||
|
||||
// Check if path segment has an @if directive
|
||||
const ifMatch = entry.match(IF_PATH_RE)
|
||||
let outputName = entry
|
||||
if (ifMatch) {
|
||||
if (!evalCondition(ifMatch[1]!, context)) continue
|
||||
outputName = ifMatch[2]!
|
||||
}
|
||||
const outputName = getOutputName(entry, context)
|
||||
if (outputName === null) continue
|
||||
|
||||
// Process @var in the output name
|
||||
outputName = outputName.replace(VAR_RE, (_match, path: string) => {
|
||||
return String(resolve(context, path) ?? "")
|
||||
})
|
||||
|
||||
const destPath = join(destDir, outputName)
|
||||
const destPath = resolveOutputPath(destDir, outputName)
|
||||
if (stat.isDirectory()) {
|
||||
renderDirInner(srcPath, destPath, context)
|
||||
} else {
|
||||
mkdirSync(destDir, { recursive: true })
|
||||
const content = readFileSync(srcPath, "utf-8")
|
||||
writeFileSync(destPath, renderContent(content, context))
|
||||
mkdirSync(dirname(destPath), { recursive: true })
|
||||
const content = readFileSync(srcPath)
|
||||
if (isUtf8Text(content)) {
|
||||
writeFileSync(destPath, renderContent(content.toString("utf-8"), context))
|
||||
} else {
|
||||
copyFileSync(srcPath, destPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user