map rendered strings to template token source
This commit is contained in:
45
README.md
45
README.md
@@ -125,6 +125,51 @@ render("./output", { web: "not a boolean", header: { show: true, title: "Hi" } }
|
||||
|
||||
For safety, tdir refuses to render into the filesystem root, the current working directory, the home directory, or any directory that overlaps the template source. Dynamic file and directory names are also resolved against the output directory and cannot write outside it.
|
||||
|
||||
## 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 plus per-file occurrences with path/range context:
|
||||
|
||||
```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 }
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"tokens": {
|
||||
"Hello": ["<@var(context.header.title)>"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Unmatched directives
|
||||
|
||||
A `<@if>` without a matching `<@endif>` throws when the renderer is initialized:
|
||||
|
||||
@@ -55,6 +55,64 @@ test("renders with web=true, header rendered", () => {
|
||||
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: "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("wrong schema throws error",() => {
|
||||
const createRenderer = initRenderer("./testdata/if_example")
|
||||
expect(() => createRenderer(z.object({
|
||||
|
||||
8
index.ts
8
index.ts
@@ -1,6 +1,8 @@
|
||||
import { z } from "zod"
|
||||
import { parse, type TemplateVariable } from "./parser"
|
||||
import { renderDir } from "./render"
|
||||
import { renderDir, type RenderOptions } from "./render"
|
||||
|
||||
export type { RenderOptions, ReverseMapFile, ReverseMapManifest, ReverseMapToken } from "./render"
|
||||
|
||||
interface Stringable {
|
||||
toString: () => string
|
||||
@@ -85,9 +87,9 @@ export const initRenderer = (dirPath: string) => {
|
||||
|
||||
const createRenderer = <S extends z.ZodType>(userSchema: S) => {
|
||||
validateSchemaMatchesTemplates(userSchema, variables)
|
||||
return (targetPath: string, context: z.infer<S>) => {
|
||||
return (targetPath: string, context: z.infer<S>, options?: RenderOptions) => {
|
||||
userSchema.parse(context)
|
||||
renderDir(dirPath, targetPath, context as Record<string, unknown>)
|
||||
renderDir(dirPath, targetPath, context as Record<string, unknown>, options)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
157
render.ts
157
render.ts
@@ -17,6 +17,41 @@ const DIRECTIVE_RE = /<@(if|elseif|else|endif)(?:\((.+?)\))?>/g
|
||||
const EQ_RE = /^eq\(context\.(.+?),\s*"(.*)"\)$/
|
||||
const PATH_RE = /^context\.(.+)$/
|
||||
|
||||
export type ReverseMapToken = {
|
||||
kind: "path" | "content"
|
||||
result: string
|
||||
token: string
|
||||
contextPath?: string
|
||||
outputPath: string
|
||||
templatePath: string
|
||||
range?: {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
}
|
||||
|
||||
export type ReverseMapFile = {
|
||||
outputPath: string
|
||||
templatePath: string
|
||||
tokens: ReverseMapToken[]
|
||||
}
|
||||
|
||||
export type ReverseMapManifest = {
|
||||
version: 1
|
||||
files: ReverseMapFile[]
|
||||
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")
|
||||
const eqMatch = expr.match(EQ_RE)
|
||||
@@ -74,16 +109,60 @@ function resolveOutputPath(destRoot: string, outputName: string): string {
|
||||
return outputPath
|
||||
}
|
||||
|
||||
function getOutputName(entry: string, context: Record<string, unknown>): string | null {
|
||||
function createReverseMapManifest(): ReverseMapManifest {
|
||||
return { version: 1, files: [], tokens: {} }
|
||||
}
|
||||
|
||||
function addReverseMapToken(
|
||||
state: RenderState | undefined,
|
||||
file: ReverseMapFile | undefined,
|
||||
token: ReverseMapToken,
|
||||
) {
|
||||
if (!state?.manifest || !file) return
|
||||
file.tokens.push(token)
|
||||
const tokens = state.manifest.tokens[token.result] ?? []
|
||||
if (!tokens.includes(token.token)) tokens.push(token.token)
|
||||
state.manifest.tokens[token.result] = tokens
|
||||
}
|
||||
|
||||
function getReverseMapPath(destRoot: string, reverseMap: boolean | 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 ?? "",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return outputName.replace(VAR_RE, (_match, path: string) => {
|
||||
return String(resolveContext(context, path) ?? "")
|
||||
const result = String(resolveContext(context, path) ?? "")
|
||||
addReverseMapToken(state, file, {
|
||||
kind: "path",
|
||||
result,
|
||||
token: _match,
|
||||
contextPath: path,
|
||||
outputPath: file?.outputPath ?? "",
|
||||
templatePath: file?.templatePath ?? "",
|
||||
})
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
@@ -162,22 +241,64 @@ function processIfBlocks(content: string, context: Record<string, unknown>): str
|
||||
}
|
||||
|
||||
function renderContent(content: string, context: Record<string, unknown>): string {
|
||||
return renderContentWithMap(content, context)
|
||||
}
|
||||
|
||||
function renderContentWithMap(
|
||||
content: string,
|
||||
context: Record<string, unknown>,
|
||||
state?: RenderState,
|
||||
file?: ReverseMapFile,
|
||||
): string {
|
||||
const processed = processIfBlocks(content, context)
|
||||
return processed.replace(VAR_RE, (_match, path: string) => {
|
||||
return String(resolveContext(context, path) ?? "")
|
||||
})
|
||||
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(
|
||||
srcDir: string,
|
||||
destDir: string,
|
||||
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 })
|
||||
renderDirInner(srcDir, destRoot, 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(
|
||||
@@ -204,6 +325,7 @@ function renderDirInner(
|
||||
srcDir: string,
|
||||
destDir: string,
|
||||
context: Record<string, unknown>,
|
||||
state: RenderState,
|
||||
) {
|
||||
mkdirSync(destDir, { recursive: true })
|
||||
const entries = readdirSync(srcDir).sort()
|
||||
@@ -211,21 +333,36 @@ function renderDirInner(
|
||||
for (const entry of entries) {
|
||||
const srcPath = resolvePath(srcDir, entry)
|
||||
const stat = statSync(srcPath)
|
||||
|
||||
const outputName = getOutputName(entry, context)
|
||||
const templatePath = relative(state.sourceRoot, srcPath)
|
||||
const tempFile: ReverseMapFile = {
|
||||
outputPath: "",
|
||||
templatePath,
|
||||
tokens: [],
|
||||
}
|
||||
const outputName = getOutputName(entry, context, state, tempFile)
|
||||
if (outputName === null) continue
|
||||
|
||||
const destPath = resolveOutputPath(destDir, outputName)
|
||||
const outputPath = relative(state.destRoot, destPath)
|
||||
tempFile.outputPath = outputPath
|
||||
for (const token of tempFile.tokens) {
|
||||
token.outputPath = outputPath
|
||||
token.templatePath = templatePath
|
||||
}
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
renderDirInner(srcPath, destPath, context)
|
||||
if (tempFile.tokens.length > 0) state.manifest?.files.push(tempFile)
|
||||
renderDirInner(srcPath, destPath, context, state)
|
||||
} else {
|
||||
mkdirSync(dirname(destPath), { recursive: true })
|
||||
const content = readFileSync(srcPath)
|
||||
if (isUtf8Text(content)) {
|
||||
writeFileSync(destPath, renderContent(content.toString("utf-8"), context))
|
||||
const rendered = renderContentWithMap(content.toString("utf-8"), context, state, tempFile)
|
||||
writeFileSync(destPath, rendered)
|
||||
} else {
|
||||
copyFileSync(srcPath, destPath)
|
||||
}
|
||||
if (tempFile.tokens.length > 0) state.manifest?.files.push(tempFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user