Files
simstudioai--sim/apps/sim/lib/execution/code-placeholders/javascript.ts
T
WeHub Mirror 6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
WeHub snapshot of cb28d14c6f2c081de7a0d8729a8c816c9adef67a
2026-08-10 11:17:50 +08:00

758 lines
26 KiB
TypeScript

import ts from '@typescript/typescript6'
import {
applySourceEdits,
CodePlaceholderCompileError,
createCodePlaceholderCompilationContext,
isOffsetInRanges,
type SourceEdit,
} from '@/lib/execution/code-placeholders/shared'
import type {
CodePlaceholderOccurrence,
CompiledCodePlaceholders,
InternalCompileCodePlaceholdersInput,
ResolvedCodePlaceholderOccurrence,
} from '@/lib/execution/code-placeholders/types'
interface SentinelOccurrence {
occurrence: CodePlaceholderOccurrence
sentinel: string
}
const SENTINEL_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
interface DecodedJavaScriptSyntax {
identifierNames: string[]
values: string[]
}
interface AnnexBHtmlCommentRange {
start: number
end: number
markerLength: 3 | 4
}
function collectDecodedSyntax(code: string): DecodedJavaScriptSyntax {
const sourceFile = ts.createSourceFile(
'user-code-original.js',
code,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.JS
)
const identifierNames: string[] = []
const values: string[] = []
const visit = (node: ts.Node): void => {
const isTemplateToken =
node.kind === ts.SyntaxKind.TemplateHead ||
node.kind === ts.SyntaxKind.TemplateMiddle ||
node.kind === ts.SyntaxKind.TemplateTail
if (ts.isIdentifier(node) || ts.isStringLiteralLike(node) || isTemplateToken) {
const text: unknown = Reflect.get(node, 'text')
if (typeof text === 'string' && text) values.push(text)
if (ts.isIdentifier(node) && node.text) identifierNames.push(node.text)
const rawText: unknown = Reflect.get(node, 'rawText')
if (typeof rawText === 'string' && rawText) values.push(rawText)
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return { identifierNames, values }
}
function collectForbiddenSentinels(
values: readonly string[],
lengths: ReadonlySet<number>
): Map<number, Set<string>> {
const forbidden = new Map<number, Set<string>>()
for (const value of values) {
for (const match of value.matchAll(/(?=(\$[0-9A-Za-z]*\$))/g)) {
const sentinel = match[1]
if (!lengths.has(sentinel.length)) continue
const entries = forbidden.get(sentinel.length) ?? new Set<string>()
entries.add(sentinel)
forbidden.set(sentinel.length, entries)
}
}
return forbidden
}
function encodeSentinelIndex(index: number, length: number): string | undefined {
const characters = new Array<string>(length).fill(SENTINEL_ALPHABET[0])
let remaining = index
for (let cursor = length - 1; cursor >= 0 && remaining > 0; cursor -= 1) {
characters[cursor] = SENTINEL_ALPHABET[remaining % SENTINEL_ALPHABET.length]
remaining = Math.floor(remaining / SENTINEL_ALPHABET.length)
}
return remaining === 0 ? characters.join('') : undefined
}
function createSentinel(
forbidden: ReadonlyMap<number, ReadonlySet<string>>,
length: number,
nextCandidateByLength: Map<number, number>
): string {
const payloadLength = length - 2
let candidateIndex = nextCandidateByLength.get(length) ?? 0
for (;;) {
const encoded = encodeSentinelIndex(candidateIndex, payloadLength)
if (!encoded) break
candidateIndex += 1
const sentinel = `$${encoded}$`
if (!forbidden.get(length)?.has(sentinel)) {
nextCandidateByLength.set(length, candidateIndex)
return sentinel
}
}
throw new CodePlaceholderCompileError('Unable to allocate a collision-free parser sentinel')
}
function createSentinelSource(
code: string,
occurrences: CodePlaceholderOccurrence[],
decodedValues: readonly string[]
): { source: string; sentinelOccurrences: SentinelOccurrence[] } {
const nextCandidateByLength = new Map<number, number>()
const lengths = new Set(occurrences.map((occurrence) => occurrence.end - occurrence.start))
const forbidden = collectForbiddenSentinels([code, ...decodedValues], lengths)
const sentinelOccurrences = occurrences.map((occurrence) => ({
occurrence,
sentinel: createSentinel(forbidden, occurrence.end - occurrence.start, nextCandidateByLength),
}))
let cursor = 0
let source = ''
for (const item of sentinelOccurrences) {
source += code.slice(cursor, item.occurrence.start)
source += item.sentinel
cursor = item.occurrence.end
}
return { source: source + code.slice(cursor), sentinelOccurrences }
}
function collectRegularExpressionRanges(sourceFile: ts.SourceFile): Array<[number, number]> {
const ranges: Array<[number, number]> = []
const visit = (node: ts.Node): void => {
if (node.kind === ts.SyntaxKind.RegularExpressionLiteral) {
ranges.push([node.getStart(sourceFile), node.getEnd()])
return
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return ranges
}
function maskSourceRanges(source: string, ranges: ReadonlyArray<[number, number]>): string {
const edits = ranges.map(([start, end]) => ({
start,
end,
text: source.slice(start, end).replace(/[^\r\n]/g, ' '),
}))
return applySourceEdits(source, edits)
}
function collectStandardCommentRanges(
source: string,
sourceFile: ts.SourceFile
): Array<[number, number]> {
const scannerSource = maskSourceRanges(source, collectRegularExpressionRanges(sourceFile))
const scanner = ts.createScanner(
ts.ScriptTarget.Latest,
false,
ts.LanguageVariant.Standard,
scannerSource
)
const ranges: Array<[number, number]> = []
for (let token = scanner.scan(); token !== ts.SyntaxKind.EndOfFileToken; token = scanner.scan()) {
if (
token === ts.SyntaxKind.SingleLineCommentTrivia ||
token === ts.SyntaxKind.MultiLineCommentTrivia ||
token === ts.SyntaxKind.ShebangTrivia
) {
ranges.push([scanner.getTokenPos(), scanner.getTextPos()])
}
}
return ranges
}
function collectJavaScriptLiteralRanges(sourceFile: ts.SourceFile): Array<[number, number]> {
const ranges: Array<[number, number]> = []
const visit = (node: ts.Node): void => {
const isTemplateToken =
node.kind === ts.SyntaxKind.NoSubstitutionTemplateLiteral ||
node.kind === ts.SyntaxKind.TemplateHead ||
node.kind === ts.SyntaxKind.TemplateMiddle ||
node.kind === ts.SyntaxKind.TemplateTail
if (
ts.isStringLiteralLike(node) ||
isTemplateToken ||
node.kind === ts.SyntaxKind.RegularExpressionLiteral
) {
ranges.push([node.getStart(sourceFile), node.getEnd()])
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return ranges
}
function collectAnnexBHtmlCommentRanges(
source: string,
sourceFile: ts.SourceFile
): AnnexBHtmlCommentRange[] {
const protectedRanges = [
...collectStandardCommentRanges(source, sourceFile),
...collectJavaScriptLiteralRanges(sourceFile),
]
const ranges: AnnexBHtmlCommentRange[] = []
let lineStart = 0
while (lineStart < source.length) {
const newline = source.indexOf('\n', lineStart)
const lineEnd = newline === -1 ? source.length : newline
const leadingWhitespace = /^\s*/.exec(source.slice(lineStart, lineEnd))?.[0].length ?? 0
const closeMarker = lineStart + leadingWhitespace
if (source.startsWith('-->', closeMarker) && !isOffsetInRanges(closeMarker, protectedRanges)) {
ranges.push({ start: closeMarker, end: lineEnd, markerLength: 3 })
lineStart = newline === -1 ? source.length : newline + 1
continue
}
let openMarker = source.indexOf('<!--', lineStart)
while (openMarker >= 0 && openMarker < lineEnd) {
if (!isOffsetInRanges(openMarker, protectedRanges)) {
ranges.push({ start: openMarker, end: lineEnd, markerLength: 4 })
break
}
openMarker = source.indexOf('<!--', openMarker + 4)
}
lineStart = newline === -1 ? source.length : newline + 1
}
return ranges
}
function createAnnexBHtmlCommentEdits(ranges: AnnexBHtmlCommentRange[]): SourceEdit[] {
return ranges.map(({ start, markerLength }) => ({
start,
end: start + markerLength,
text: markerLength === 4 ? '// ' : '// ',
}))
}
function assertSyntacticallyValidJavaScript(
source: string,
originalCode: string,
sourceFile: ts.SourceFile
): void {
const diagnostics = Reflect.get(sourceFile, 'parseDiagnostics') as
| readonly ts.Diagnostic[]
| undefined
const diagnostic = diagnostics?.[0]
if (!diagnostic) return
throw new CodePlaceholderCompileError(
`Invalid JavaScript syntax: ${ts.flattenDiagnosticMessageText(diagnostic.messageText, ' ')}`,
originalCode,
diagnostic.start ?? source.length
)
}
function occurrencesInNode(
node: ts.Node,
sourceFile: ts.SourceFile,
sentinelOccurrences: SentinelOccurrence[]
): SentinelOccurrence[] {
const start = node.getStart(sourceFile)
const end = node.getEnd()
let lower = 0
let upper = sentinelOccurrences.length
while (lower < upper) {
const middle = (lower + upper) >>> 1
if (sentinelOccurrences[middle].occurrence.start < start) lower = middle + 1
else upper = middle
}
const items: SentinelOccurrence[] = []
for (let index = lower; index < sentinelOccurrences.length; index += 1) {
const item = sentinelOccurrences[index]
if (item.occurrence.start >= end) break
if (item.occurrence.end <= end) items.push(item)
}
return items
}
function restoreUnresolvedSentinels(value: string, items: SentinelOccurrence[]): string {
const rawBySentinel = new Map(items.map((item) => [item.sentinel, item.occurrence.raw]))
return value.replace(/\$[0-9A-Za-z]*\$/g, (sentinel) => rawBySentinel.get(sentinel) ?? sentinel)
}
const UNSAFE_JAVASCRIPT_SOURCE_CHARACTERS = /[<>\u2028\u2029]/g
/** Serializes a string literal without embedding HTML terminators or JavaScript line separators. */
function serializeJavaScriptStringLiteral(value: string): string {
return JSON.stringify(value).replace(
UNSAFE_JAVASCRIPT_SOURCE_CHARACTERS,
(character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`
)
}
function buildStringExpression(
value: string,
items: SentinelOccurrence[],
resolve: (occurrence: CodePlaceholderOccurrence) => ResolvedCodePlaceholderOccurrence | undefined,
consumed: Set<CodePlaceholderOccurrence>
): string | undefined {
const bySentinel = new Map(items.map((item) => [item.sentinel, item]))
const restore = (text: string): string =>
text.replace(/\$[0-9A-Za-z]*\$/g, (sentinel) => {
const item = bySentinel.get(sentinel)
return item?.occurrence.raw ?? sentinel
})
const parts: string[] = []
let cursor = 0
let matched = false
for (const match of value.matchAll(/\$[0-9A-Za-z]*\$/g)) {
const item = bySentinel.get(match[0])
if (!item || match.index === undefined) continue
const resolved = resolve(item.occurrence)
if (!resolved) continue
matched = true
parts.push(serializeJavaScriptStringLiteral(restore(value.slice(cursor, match.index))))
parts.push(resolved.bindingName)
cursor = match.index + match[0].length
consumed.add(item.occurrence)
}
if (!matched) return undefined
parts.push(serializeJavaScriptStringLiteral(restore(value.slice(cursor))))
return `(${parts.join(' + ')})`
}
function isStaticModuleSpecifier(node: ts.StringLiteral): boolean {
const parent = node.parent
return (
((ts.isImportDeclaration(parent) || ts.isExportDeclaration(parent)) &&
parent.moduleSpecifier === node) ||
(ts.isExternalModuleReference(parent) && parent.expression === node)
)
}
function isPropertyName(node: ts.Node): boolean {
const parent = node.parent
return (
((ts.isPropertyAssignment(parent) ||
ts.isMethodDeclaration(parent) ||
ts.isGetAccessorDeclaration(parent) ||
ts.isSetAccessorDeclaration(parent) ||
ts.isPropertyDeclaration(parent) ||
ts.isPropertySignature(parent) ||
ts.isMethodSignature(parent)) &&
parent.name === node) ||
(ts.isBindingElement(parent) && parent.propertyName === node)
)
}
function isDeclarationIdentifier(node: ts.Identifier): boolean {
const parent = node.parent
if (ts.isLabeledStatement(parent)) return parent.label === node
return (
((ts.isVariableDeclaration(parent) ||
ts.isParameter(parent) ||
ts.isFunctionDeclaration(parent) ||
ts.isFunctionExpression(parent) ||
ts.isClassDeclaration(parent) ||
ts.isClassExpression(parent) ||
ts.isBindingElement(parent) ||
ts.isTypeParameterDeclaration(parent) ||
ts.isImportClause(parent) ||
ts.isImportSpecifier(parent) ||
ts.isNamespaceImport(parent) ||
ts.isExportSpecifier(parent)) &&
parent.name === node) ||
((ts.isBreakStatement(parent) || ts.isContinueStatement(parent)) && parent.label === node)
)
}
function isWriteIdentifier(node: ts.Identifier): boolean {
let current: ts.Node = node
let targetPosition = true
for (let parent = current.parent; parent; current = parent, parent = parent.parent) {
if (
(ts.isPrefixUnaryExpression(parent) || ts.isPostfixUnaryExpression(parent)) &&
parent.operand === current
) {
return (
targetPosition &&
(parent.operator === ts.SyntaxKind.PlusPlusToken ||
parent.operator === ts.SyntaxKind.MinusMinusToken)
)
}
if (
ts.isBinaryExpression(parent) &&
parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment
) {
if (parent.left === current) return targetPosition
if (parent.right === current) targetPosition = false
continue
}
if (
(ts.isForInStatement(parent) || ts.isForOfStatement(parent)) &&
parent.initializer === current
) {
return targetPosition
}
if (ts.isComputedPropertyName(parent)) {
targetPosition = false
continue
}
if (ts.isPropertyAssignment(parent)) {
if (parent.name === current) targetPosition = false
continue
}
if (
ts.isPropertyAccessExpression(parent) ||
ts.isElementAccessExpression(parent) ||
ts.isCallExpression(parent) ||
ts.isNewExpression(parent)
) {
targetPosition = false
continue
}
if (
ts.isParenthesizedExpression(parent) ||
ts.isArrayLiteralExpression(parent) ||
ts.isObjectLiteralExpression(parent) ||
ts.isShorthandPropertyAssignment(parent) ||
ts.isSpreadElement(parent)
) {
continue
}
targetPosition = false
}
return false
}
type TemplateToken =
| ts.NoSubstitutionTemplateLiteral
| ts.TemplateHead
| ts.TemplateMiddle
| ts.TemplateTail
const CONTAINS_INVALID_TEMPLATE_ESCAPE_FLAG = 1 << 11
function getTaggedTemplateTokens(node: ts.TaggedTemplateExpression): TemplateToken[] {
if (ts.isNoSubstitutionTemplateLiteral(node.template)) return [node.template]
return [node.template.head, ...node.template.templateSpans.map((span) => span.literal)]
}
function getTemplateTokenRawText(token: TemplateToken): string {
if (token.rawText !== undefined) return token.rawText
const text = token.getText()
if (ts.isNoSubstitutionTemplateLiteral(token)) return text.slice(1, -1)
if (ts.isTemplateHead(token) || ts.isTemplateMiddle(token)) return text.slice(1, -2)
return text.slice(1, -1)
}
function hasInvalidTemplateEscape(token: TemplateToken): boolean {
const flags: unknown = Reflect.get(token, 'templateFlags')
return typeof flags === 'number' && (flags & CONTAINS_INVALID_TEMPLATE_ESCAPE_FLAG) !== 0
}
function replaceSentinelsInTemplateToken(
tokenText: string,
tokenStart: number,
items: SentinelOccurrence[],
resolve: (occurrence: CodePlaceholderOccurrence) => ResolvedCodePlaceholderOccurrence | undefined,
consumed: Set<CodePlaceholderOccurrence>
): string {
let result = ''
let cursor = 0
for (const item of [...items].sort(
(left, right) => left.occurrence.start - right.occurrence.start
)) {
const markerStart = item.occurrence.start - tokenStart
let precedingText = tokenText.slice(cursor, markerStart)
const resolved = resolve(item.occurrence)
if (resolved) {
let precedingBackslashes = 0
for (let index = precedingText.length - 1; precedingText[index] === '\\'; index -= 1) {
precedingBackslashes += 1
}
if (precedingBackslashes % 2 === 1) precedingText = precedingText.slice(0, -1)
result += `${precedingText}\${${resolved.bindingName}}`
consumed.add(item.occurrence)
} else {
result += precedingText + item.occurrence.raw
}
cursor = markerStart + item.sentinel.length
}
return result + tokenText.slice(cursor)
}
function regexParts(text: string): { pattern: string; flags: string } | undefined {
if (!text.startsWith('/')) return undefined
const lastSlash = text.lastIndexOf('/')
if (lastSlash <= 0) return undefined
return { pattern: text.slice(1, lastSlash), flags: text.slice(lastSlash + 1) }
}
export async function compileJavaScriptPlaceholders(
input: InternalCompileCodePlaceholdersInput
): Promise<CompiledCodePlaceholders> {
const decodedSyntax = collectDecodedSyntax(input.code)
const context = createCodePlaceholderCompilationContext({
...input,
reservedNames: [...(input.reservedNames ?? []), ...decodedSyntax.identifierNames],
})
if (context.occurrences.length === 0) {
const sourceFile = ts.createSourceFile(
'user-code.js',
input.code,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.JS
)
const htmlCommentRanges = collectAnnexBHtmlCommentRanges(input.code, sourceFile)
return context.finish(
applySourceEdits(input.code, createAnnexBHtmlCommentEdits(htmlCommentRanges))
)
}
const { source, sentinelOccurrences } = createSentinelSource(
input.code,
context.occurrences,
decodedSyntax.values
)
const preliminarySourceFile = ts.createSourceFile(
'user-code.js',
source,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.JS
)
const htmlCommentRanges = collectAnnexBHtmlCommentRanges(source, preliminarySourceFile)
const parserSource = maskSourceRanges(
source,
htmlCommentRanges.map(({ start, end }) => [start, end])
)
const sourceFile = ts.createSourceFile(
'user-code.js',
parserSource,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.JS
)
if (!input.analysisOnly) assertSyntacticallyValidJavaScript(source, input.code, sourceFile)
const commentRanges: Array<[number, number]> = [
...collectStandardCommentRanges(parserSource, sourceFile),
...htmlCommentRanges.map(({ start, end }) => [start, end] as [number, number]),
]
const itemByRange = new Map(
sentinelOccurrences.map((item) => [`${item.occurrence.start}:${item.occurrence.end}`, item])
)
const consumed = new Set<CodePlaceholderOccurrence>()
const edits = createAnnexBHtmlCommentEdits(htmlCommentRanges)
let taggedTemplateSiteIndex = 0
const rejectUnsupported = (item: SentinelOccurrence, contextName: string): void => {
if (input.analysisOnly) {
context.resolveValue(item.occurrence)
consumed.add(item.occurrence)
return
}
throw new CodePlaceholderCompileError(
`Variable placeholder "${item.occurrence.name}" is not supported in ${contextName}`,
input.code,
item.occurrence.start
)
}
const visit = (node: ts.Node): void => {
if (ts.isTaggedTemplateExpression(node)) {
const tokens = getTaggedTemplateTokens(node)
const tokenItems = tokens.map((token) =>
occurrencesInNode(token, sourceFile, sentinelOccurrences)
)
const hasResolvedLiteralPlaceholder = tokenItems.some((items) =>
items.some((item) => context.hasValue(item.occurrence.name))
)
if (!hasResolvedLiteralPlaceholder) {
ts.forEachChild(node, visit)
return
}
const nestedEditStart = edits.length
visit(node.tag)
if (ts.isTemplateExpression(node.template)) {
for (const span of node.template.templateSpans) visit(span.expression)
}
const nestedEdits = edits.splice(nestedEditStart)
const transformedNodeText = (child: ts.Node): string => {
const childStart = child.getStart(sourceFile)
const childEnd = child.getEnd()
const childEdits = nestedEdits
.filter((edit) => edit.start >= childStart && edit.end <= childEnd)
.map((edit) => ({
...edit,
start: edit.start - childStart,
end: edit.end - childStart,
}))
return restoreUnresolvedSentinels(
applySourceEdits(source.slice(childStart, childEnd), childEdits),
occurrencesInNode(child, sourceFile, sentinelOccurrences)
)
}
const segmentExpression = (value: string, items: SentinelOccurrence[]): string =>
buildStringExpression(value, items, context.resolve, consumed) ??
serializeJavaScriptStringLiteral(restoreUnresolvedSentinels(value, items))
const cookedSegments = tokens.map((token, index) =>
hasInvalidTemplateEscape(token)
? 'undefined'
: segmentExpression(token.text, tokenItems[index])
)
const rawSegments = tokens.map((token, index) =>
segmentExpression(getTemplateTokenRawText(token), tokenItems[index])
)
const intrinsics = context.runtimeBindingFor('javascript-runtime')
const templateObject = [
'((cooked, raw, intrinsics) =>',
'intrinsics.freeze(intrinsics.defineProperty(cooked, "raw",',
'{ value: intrinsics.freeze(raw) })))',
`([${cookedSegments.join(', ')}], [${rawSegments.join(', ')}], ${intrinsics.name})`,
].join(' ')
const cachedTemplateObject = `${intrinsics.name}.template(${taggedTemplateSiteIndex}, () => ${templateObject})`
taggedTemplateSiteIndex += 1
const substitutionExpressions = ts.isTemplateExpression(node.template)
? node.template.templateSpans.map((span) => transformedNodeText(span.expression))
: []
edits.push({
start: node.getStart(sourceFile),
end: node.getEnd(),
text: `((${transformedNodeText(node.tag)})(${[
cachedTemplateObject,
...substitutionExpressions,
].join(', ')}))`,
})
return
}
if (ts.isStringLiteral(node)) {
const items = occurrencesInNode(node, sourceFile, sentinelOccurrences)
if (items.length === 0) return
if (isStaticModuleSpecifier(node)) {
const resolved = items.find((item) => context.hasValue(item.occurrence.name))
if (resolved) rejectUnsupported(resolved, 'a static import or export specifier')
return
}
const expression = buildStringExpression(node.text, items, context.resolve, consumed)
if (!expression) return
edits.push({
start: node.getStart(sourceFile),
end: node.getEnd(),
text: isPropertyName(node) ? `[${expression}]` : expression,
})
return
}
if (
node.kind === ts.SyntaxKind.NoSubstitutionTemplateLiteral ||
node.kind === ts.SyntaxKind.TemplateHead ||
node.kind === ts.SyntaxKind.TemplateMiddle ||
node.kind === ts.SyntaxKind.TemplateTail
) {
const text = node.getText(sourceFile)
const items = occurrencesInNode(node, sourceFile, sentinelOccurrences)
if (items.length === 0) return
const templateRoot = ts.isTemplateExpression(node.parent)
? node.parent
: ts.isTemplateSpan(node.parent) && ts.isTemplateExpression(node.parent.parent)
? node.parent.parent
: node
if (ts.isTaggedTemplateExpression(templateRoot.parent)) return
const replacement = replaceSentinelsInTemplateToken(
text,
node.getStart(sourceFile),
items,
context.resolve,
consumed
)
edits.push({ start: node.getStart(sourceFile), end: node.getEnd(), text: replacement })
return
}
if (node.kind === ts.SyntaxKind.RegularExpressionLiteral) {
const text = node.getText(sourceFile)
const items = occurrencesInNode(node, sourceFile, sentinelOccurrences)
if (items.length === 0) return
const parsed = regexParts(text)
if (!parsed) {
rejectUnsupported(items[0], 'a regular-expression literal')
return
}
const expression = buildStringExpression(parsed.pattern, items, context.resolve, consumed)
if (!expression) return
const intrinsics = context.runtimeBindingFor('javascript-runtime')
edits.push({
start: node.getStart(sourceFile),
end: node.getEnd(),
text: `new ${intrinsics.name}.RegExp(${expression}, ${serializeJavaScriptStringLiteral(parsed.flags)})`,
})
return
}
if (ts.isIdentifier(node)) {
const item = itemByRange.get(`${node.getStart(sourceFile)}:${node.getEnd()}`)
if (!item || isOffsetInRanges(item.occurrence.start, commentRanges)) return
const resolved = context.resolve(item.occurrence)
if (!resolved) return
if (isDeclarationIdentifier(node) || isWriteIdentifier(node)) {
rejectUnsupported(item, 'a declaration, label, or assignment target')
return
}
const accessor = resolved.bindingName
const parent = node.parent
if (ts.isPropertyAccessExpression(parent) && parent.name === node) {
edits.push({
start: parent.expression.getEnd(),
end: node.getEnd(),
text: parent.questionDotToken ? `?.[${accessor}]` : `[${accessor}]`,
})
} else if (ts.isShorthandPropertyAssignment(parent) && parent.name === node) {
edits.push({
start: node.getStart(sourceFile),
end: node.getEnd(),
text: `[${accessor}]: ${accessor}`,
})
} else if (isPropertyName(node)) {
edits.push({
start: node.getStart(sourceFile),
end: node.getEnd(),
text: `[${accessor}]`,
})
} else if (
parent.kind >= ts.SyntaxKind.JsxElement &&
parent.kind <= ts.SyntaxKind.JsxAttribute
) {
rejectUnsupported(item, 'JSX syntax')
return
} else {
edits.push({ start: node.getStart(sourceFile), end: node.getEnd(), text: accessor })
}
consumed.add(item.occurrence)
return
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
for (const item of sentinelOccurrences) {
if (isOffsetInRanges(item.occurrence.start, commentRanges)) continue
if (!context.hasValue(item.occurrence.name) || consumed.has(item.occurrence)) continue
rejectUnsupported(item, 'this JavaScript syntax position')
}
const transformed = applySourceEdits(input.code, edits)
return context.finish(transformed)
}