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.
|
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
|
## Unmatched directives
|
||||||
|
|
||||||
A `<@if>` without a matching `<@endif>` throws when the renderer is initialized:
|
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>")
|
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",() => {
|
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({
|
||||||
|
|||||||
8
index.ts
8
index.ts
@@ -1,6 +1,8 @@
|
|||||||
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, ReverseMapToken } from "./render"
|
||||||
|
|
||||||
interface Stringable {
|
interface Stringable {
|
||||||
toString: () => string
|
toString: () => string
|
||||||
@@ -85,9 +87,9 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
155
render.ts
155
render.ts
@@ -17,6 +17,41 @@ const DIRECTIVE_RE = /<@(if|elseif|else|endif)(?:\((.+?)\))?>/g
|
|||||||
const EQ_RE = /^eq\(context\.(.+?),\s*"(.*)"\)$/
|
const EQ_RE = /^eq\(context\.(.+?),\s*"(.*)"\)$/
|
||||||
const PATH_RE = /^context\.(.+)$/
|
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 {
|
function evalCondition(expr: string | undefined, context: Record<string, unknown>): boolean {
|
||||||
if (!expr) throw new Error("Missing condition expression")
|
if (!expr) throw new Error("Missing condition expression")
|
||||||
const eqMatch = expr.match(EQ_RE)
|
const eqMatch = expr.match(EQ_RE)
|
||||||
@@ -74,16 +109,60 @@ function resolveOutputPath(destRoot: string, outputName: string): string {
|
|||||||
return outputPath
|
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)
|
const ifMatch = entry.match(IF_PATH_RE)
|
||||||
let outputName = entry
|
let outputName = entry
|
||||||
if (ifMatch) {
|
if (ifMatch) {
|
||||||
if (!evalCondition(ifMatch[1], context)) return null
|
if (!evalCondition(ifMatch[1], context)) return null
|
||||||
outputName = ifMatch[2]!
|
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 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 {
|
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)
|
const processed = processIfBlocks(content, context)
|
||||||
return processed.replace(VAR_RE, (_match, path: string) => {
|
let result = ""
|
||||||
return String(resolveContext(context, path) ?? "")
|
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 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)
|
assertSafeRenderTarget(srcDir, destDir)
|
||||||
validateOutputPaths(srcDir, destRoot, context)
|
validateOutputPaths(srcDir, destRoot, context)
|
||||||
rmSync(destDir, { recursive: true, force: true })
|
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(
|
function validateOutputPaths(
|
||||||
@@ -204,6 +325,7 @@ 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()
|
||||||
@@ -211,21 +333,36 @@ function renderDirInner(
|
|||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const srcPath = resolvePath(srcDir, entry)
|
const srcPath = resolvePath(srcDir, entry)
|
||||||
const stat = statSync(srcPath)
|
const stat = statSync(srcPath)
|
||||||
|
const templatePath = relative(state.sourceRoot, srcPath)
|
||||||
const outputName = getOutputName(entry, context)
|
const tempFile: ReverseMapFile = {
|
||||||
|
outputPath: "",
|
||||||
|
templatePath,
|
||||||
|
tokens: [],
|
||||||
|
}
|
||||||
|
const outputName = getOutputName(entry, context, state, tempFile)
|
||||||
if (outputName === null) continue
|
if (outputName === null) continue
|
||||||
|
|
||||||
const destPath = resolveOutputPath(destDir, outputName)
|
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()) {
|
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(dirname(destPath), { recursive: true })
|
mkdirSync(dirname(destPath), { recursive: true })
|
||||||
const content = readFileSync(srcPath)
|
const content = readFileSync(srcPath)
|
||||||
if (isUtf8Text(content)) {
|
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 {
|
} else {
|
||||||
copyFileSync(srcPath, destPath)
|
copyFileSync(srcPath, destPath)
|
||||||
}
|
}
|
||||||
|
if (tempFile.tokens.length > 0) state.manifest?.files.push(tempFile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user