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
758 lines
26 KiB
TypeScript
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)
|
|
}
|