full template restoreablity
Some checks failed
Publish npm package / publish (push) Failing after 21s
Some checks failed
Publish npm package / publish (push) Failing after 21s
This commit is contained in:
295
render.ts
295
render.ts
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user