full template restoreablity
Some checks failed
Publish npm package / publish (push) Failing after 21s

This commit is contained in:
Gregor Lohaus
2026-05-24 12:17:12 +02:00
parent 8ad2545310
commit af0c25e64b
5 changed files with 399 additions and 61 deletions

295
render.ts
View File

@@ -18,7 +18,7 @@ const EQ_RE = /^eq\(context\.(.+?),\s*"(.*)"\)$/
const PATH_RE = /^context\.(.+)$/
export type ReverseMapToken = {
kind: "path" | "content"
kind: "path" | "content" | "conditional"
result: string
token: string
contextPath?: string
@@ -28,6 +28,12 @@ export type ReverseMapToken = {
start: number
end: number
}
activeRange?: {
start: number
end: number
}
before?: string
after?: string
}
export type ReverseMapFile = {
@@ -36,9 +42,17 @@ export type ReverseMapFile = {
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[]>
}
@@ -110,7 +124,7 @@ function resolveOutputPath(destRoot: string, outputName: string): string {
}
function createReverseMapManifest(): ReverseMapManifest {
return { version: 1, files: [], tokens: {} }
return { version: 1, files: [], skipped: [], tokens: {} }
}
function addReverseMapToken(
@@ -176,70 +190,190 @@ function isUtf8Text(buffer: Buffer): boolean {
}
}
// Each stack frame tracks: did any branch match yet, is the current branch active
type IfFrame = { matched: boolean; active: boolean; sawElse: boolean }
type DirectiveToken = {
type: "if" | "elseif" | "else" | "endif"
condition?: string
index: number
end: number
}
function processIfBlocks(content: string, context: Record<string, unknown>): string {
let result = ""
let pos = 0
const stack: IfFrame[] = []
type TextNode = {
type: "text"
text: string
}
const re = new RegExp(DIRECTIVE_RE.source, "g")
let match: RegExpExecArray | null
type IfBranch = {
type: "if" | "elseif" | "else"
condition?: string
nodes: TemplateNode[]
contentStart: number
contentEnd: number
}
function isEmitting(): boolean {
return stack.every(f => f.active)
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
}
}
while ((match = re.exec(content)) !== null) {
const directive = match[1]!
const condPath = match[2]
function getDirectiveTokens(content: string): DirectiveToken[] {
return Array.from(content.matchAll(DIRECTIVE_RE), match => ({
type: match[1] as DirectiveToken["type"],
condition: match[2],
index: match.index!,
end: match.index! + match[0].length,
}))
}
if (directive === "if") {
if (isEmitting()) result += content.slice(pos, match.index)
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)
top.matched = truthy
top.active = truthy
}
pos = re.lastIndex
} else if (directive === "else") {
if (stack.length === 0) throw new Error("Unexpected <@else> without <@if>")
const top = stack[stack.length - 1]!
if (top.sawElse) throw new Error("Unexpected duplicate <@else>")
if (isEmitting()) result += content.slice(pos, match.index)
top.active = !top.matched
top.matched = true
top.sawElse = true
pos = re.lastIndex
} else if (directive === "endif") {
if (stack.length === 0) throw new Error("Unexpected <@endif> without <@if>")
if (isEmitting()) result += content.slice(pos, match.index)
stack.pop()
pos = re.lastIndex
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 (stack.length > 0) {
throw new Error("Unmatched <@if> without <@endif>")
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
}
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 {
return renderContentWithMap(content, context)
}
@@ -250,7 +384,22 @@ function renderContentWithMap(
state?: RenderState,
file?: ReverseMapFile,
): string {
const processed = processIfBlocks(content, context)
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
@@ -321,6 +470,39 @@ function validateOutputPaths(
}
}
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(
srcDir: string,
destDir: string,
@@ -340,7 +522,10 @@ function renderDirInner(
tokens: [],
}
const outputName = getOutputName(entry, context, state, tempFile)
if (outputName === null) continue
if (outputName === null) {
storeSkippedTemplate(srcPath, state)
continue
}
const destPath = resolveOutputPath(destDir, outputName)
const outputPath = relative(state.destRoot, destPath)
@@ -362,7 +547,7 @@ function renderDirInner(
} else {
copyFileSync(srcPath, destPath)
}
if (tempFile.tokens.length > 0) state.manifest?.files.push(tempFile)
if (state.manifest) state.manifest.files.push(tempFile)
}
}
}