Skip to content

Commit 63119ab

Browse files
feat: Phase 2 — template pre-compilation and hydration system
Added three new modules for the production build pipeline: placeholder.ts: - createPlaceholder(): generates unique tokens for dynamic expressions - replacePlaceholders(): fast string replacement at serve time - hasPlaceholders(): detect if HTML needs hydration template-compiler.ts: - compileTemplate(): runs processDirectives in build mode - Produces CompiledTemplate with HTML, fragment, placeholder map, server script content, dependencies, and content hash - Server scripts are NOT executed at build time (deferred to serve) - Uses recording Proxy context to track dynamic variable access template-hydrator.ts: - hydrateTemplate(): resolves placeholders with request-time data - hydrateFragment(): same for SPA navigation fragments - Only executes server scripts and evaluates dynamic expressions - Static pages return pre-rendered HTML as-is (zero work) 12 new tests. All 8028 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b0655fc commit 63119ab

File tree

5 files changed

+556
-0
lines changed

5 files changed

+556
-0
lines changed

packages/stx/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export * from './loops'
6767
export * from './markdown'
6868
export * from './middleware'
6969
export * from './parser'
70+
export * from './placeholder'
7071
export * from './process'
7172
export * from './production-build'
7273
export * from './release'
@@ -76,6 +77,8 @@ export * from './script-classifier'
7677
export * from './seo'
7778
export * from './serve'
7879
export * from './style-scoping'
80+
export * from './template-compiler'
81+
export * from './template-hydrator'
7982
export * from './streaming'
8083
export * from './suspense'
8184
export * from './teleport'

packages/stx/src/placeholder.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* Placeholder Token System
3+
*
4+
* Generates and resolves placeholder tokens for the template pre-compilation pipeline.
5+
* During build mode, dynamic expressions are replaced with placeholder tokens.
6+
* At serve time, only the placeholders are resolved with request-time data.
7+
*
8+
* @module placeholder
9+
*/
10+
11+
let placeholderCounter = 0
12+
13+
/**
14+
* Reset the placeholder counter (for testing).
15+
*/
16+
export function resetPlaceholders(): void {
17+
placeholderCounter = 0
18+
}
19+
20+
/**
21+
* Create a unique placeholder token.
22+
*
23+
* @param type - The type of placeholder ('expr' for expressions, 'cond' for conditionals)
24+
* @param expr - The original expression (stored in the compiled template metadata)
25+
* @returns A unique placeholder token string
26+
*/
27+
export function createPlaceholder(type: 'expr' | 'cond' | 'raw', expr: string): string {
28+
const id = placeholderCounter++
29+
// Use HTML comments so they survive HTML parsing and don't affect layout
30+
return `<!--__STX_${type.toUpperCase()}_${id}__-->`
31+
}
32+
33+
/**
34+
* A map of placeholder tokens to their original expressions.
35+
*/
36+
export interface PlaceholderMap {
37+
[token: string]: {
38+
type: 'expr' | 'cond' | 'raw'
39+
expression: string
40+
}
41+
}
42+
43+
/**
44+
* Extract all placeholder tokens from compiled HTML.
45+
*
46+
* @returns A map of token → expression info
47+
*/
48+
export function extractPlaceholders(html: string): PlaceholderMap {
49+
const map: PlaceholderMap = {}
50+
const regex = /<!--__STX_(EXPR|COND|RAW)_(\d+)__-->/g
51+
let match: RegExpExecArray | null
52+
53+
while ((match = regex.exec(html)) !== null) {
54+
const type = match[1].toLowerCase() as 'expr' | 'cond' | 'raw'
55+
map[match[0]] = { type, expression: '' } // Expression stored in compiled template metadata
56+
}
57+
58+
return map
59+
}
60+
61+
/**
62+
* Replace placeholder tokens with evaluated values.
63+
* Uses simple string replacement for maximum performance.
64+
*
65+
* @param html - The compiled HTML with placeholder tokens
66+
* @param values - Map of placeholder token → resolved value
67+
* @returns HTML with all placeholders replaced
68+
*/
69+
export function replacePlaceholders(html: string, values: Map<string, string>): string {
70+
let result = html
71+
for (const [token, value] of values) {
72+
// Use split/join for reliable replacement (no regex special char issues)
73+
result = result.split(token).join(value)
74+
}
75+
return result
76+
}
77+
78+
/**
79+
* Check if HTML contains any placeholder tokens.
80+
*/
81+
export function hasPlaceholders(html: string): boolean {
82+
return html.includes('<!--__STX_')
83+
}
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/**
2+
* Template Compiler
3+
*
4+
* Pre-compiles .stx templates at build time by running the full processDirectives
5+
* pipeline with a recording context. Dynamic expressions that can't be evaluated
6+
* at build time are replaced with placeholder tokens.
7+
*
8+
* @module template-compiler
9+
*/
10+
11+
import fs from 'node:fs'
12+
import path from 'node:path'
13+
import { processDirectives } from './process'
14+
import { defaultConfig, loadStxConfig } from './config'
15+
import { extractVariables } from './variable-extractor'
16+
import { createPlaceholder, resetPlaceholders, type PlaceholderMap } from './placeholder'
17+
import { stripDocumentWrapper } from './app-shell'
18+
import { createHash } from 'node:crypto'
19+
20+
/**
21+
* A pre-compiled template ready for serve-time hydration.
22+
*/
23+
export interface CompiledTemplate {
24+
/** The route this template serves */
25+
route: string
26+
/** Source file path */
27+
sourceFile: string
28+
/** Pre-rendered HTML (with placeholders for dynamic content) */
29+
html: string
30+
/** SPA fragment (main content only, no document wrapper) */
31+
fragment: string
32+
/** Map of placeholder tokens to their original expressions */
33+
placeholders: PlaceholderMap
34+
/** Whether this page has server scripts that need request-time execution */
35+
hasServerScripts: boolean
36+
/** Raw server script content (for serve-time execution) */
37+
serverScriptContent: string[]
38+
/** All file dependencies (for cache invalidation) */
39+
dependencies: string[]
40+
/** Content hash of the source file */
41+
contentHash: string
42+
}
43+
44+
/**
45+
* Create a recording context proxy that tracks which variables were accessed
46+
* but had no value. These become placeholder candidates.
47+
*/
48+
function createRecordingContext(baseContext: Record<string, any> = {}): {
49+
context: Record<string, any>
50+
accessedKeys: Set<string>
51+
} {
52+
const accessedKeys = new Set<string>()
53+
54+
const context = new Proxy(baseContext, {
55+
get(target, prop: string) {
56+
if (prop in target) {
57+
return target[prop]
58+
}
59+
// Record the access — this key was requested but doesn't exist
60+
if (typeof prop === 'string' && !prop.startsWith('__')) {
61+
accessedKeys.add(prop)
62+
}
63+
return undefined
64+
},
65+
has(target, prop: string) {
66+
if (prop in target) return true
67+
if (typeof prop === 'string' && !prop.startsWith('__')) {
68+
accessedKeys.add(prop)
69+
}
70+
return false
71+
},
72+
})
73+
74+
return { context, accessedKeys }
75+
}
76+
77+
/**
78+
* Compile a single .stx template file.
79+
*
80+
* Runs the full processDirectives pipeline in build mode ('compile'),
81+
* producing pre-rendered HTML with placeholder tokens for dynamic content.
82+
*
83+
* @param filePath - Absolute path to the .stx file
84+
* @param route - The URL route this page serves (e.g. '/jobs')
85+
* @param options - Additional compilation options
86+
*/
87+
export async function compileTemplate(
88+
filePath: string,
89+
route: string,
90+
options: {
91+
componentsDir?: string
92+
partialsDir?: string
93+
layoutsDir?: string
94+
debug?: boolean
95+
} = {},
96+
): Promise<CompiledTemplate> {
97+
const absolutePath = path.resolve(filePath)
98+
const content = await Bun.file(absolutePath).text()
99+
const contentHash = createHash('sha256').update(content).digest('hex').slice(0, 16)
100+
101+
// Reset placeholder counter for deterministic output
102+
resetPlaceholders()
103+
104+
// Load project config
105+
let projectConfig: Record<string, any> = {}
106+
try {
107+
projectConfig = await loadStxConfig()
108+
}
109+
catch {
110+
// No config file — use defaults
111+
}
112+
113+
// Extract server scripts for serve-time execution
114+
const serverScriptContent: string[] = []
115+
const scriptRegex = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi
116+
let scriptMatch: RegExpExecArray | null
117+
while ((scriptMatch = scriptRegex.exec(content)) !== null) {
118+
const attrs = scriptMatch[1]
119+
if (/\bserver\b/.test(attrs)) {
120+
serverScriptContent.push(scriptMatch[2])
121+
}
122+
}
123+
124+
// Create a recording context — server scripts are NOT executed at build time
125+
// (they may depend on request data like session, DB queries, etc.)
126+
const { context } = createRecordingContext({
127+
__filename: absolutePath,
128+
__dirname: path.dirname(absolutePath),
129+
})
130+
131+
// Track dependencies
132+
const dependencies = new Set<string>()
133+
134+
// Build the options for processDirectives
135+
const opts = {
136+
...defaultConfig,
137+
...projectConfig,
138+
...options,
139+
buildMode: 'compile' as const,
140+
strict: false,
141+
}
142+
143+
// Run the full pipeline in compile mode
144+
const html = await processDirectives(content, context, absolutePath, opts, dependencies)
145+
146+
// Extract the SPA fragment (content inside <main> or the body without document wrapper)
147+
const fragment = stripDocumentWrapper(html)
148+
149+
// Build placeholder map from the compiled output
150+
const placeholders: PlaceholderMap = {}
151+
const placeholderRegex = /<!--__STX_(EXPR|COND|RAW)_(\d+)__-->/g
152+
let phMatch: RegExpExecArray | null
153+
while ((phMatch = placeholderRegex.exec(html)) !== null) {
154+
placeholders[phMatch[0]] = {
155+
type: phMatch[1].toLowerCase() as 'expr' | 'cond' | 'raw',
156+
expression: '', // Will be populated by the expression processor
157+
}
158+
}
159+
160+
return {
161+
route,
162+
sourceFile: filePath,
163+
html,
164+
fragment,
165+
placeholders,
166+
hasServerScripts: serverScriptContent.length > 0,
167+
serverScriptContent,
168+
dependencies: Array.from(dependencies),
169+
contentHash,
170+
}
171+
}

0 commit comments

Comments
 (0)