@zenithbuild/core 0.6.3 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli/commands/dev.ts +107 -48
- package/compiler/discovery/componentDiscovery.ts +75 -11
- package/compiler/output/types.ts +15 -1
- package/compiler/parse/parseTemplate.ts +29 -0
- package/compiler/runtime/dataExposure.ts +27 -12
- package/compiler/runtime/generateDOM.ts +12 -3
- package/compiler/runtime/transformIR.ts +36 -0
- package/compiler/runtime/wrapExpression.ts +32 -13
- package/compiler/runtime/wrapExpressionWithLoop.ts +24 -10
- package/compiler/ssg-build.ts +71 -7
- package/compiler/test/component-stacking.test.ts +365 -0
- package/compiler/transform/componentResolver.ts +42 -4
- package/compiler/transform/fragmentLowering.ts +153 -1
- package/compiler/transform/generateBindings.ts +31 -10
- package/compiler/transform/transformNode.ts +114 -1
- package/core/config/index.ts +5 -3
- package/core/config/types.ts +67 -37
- package/core/plugins/bridge.ts +193 -0
- package/core/plugins/registry.ts +51 -6
- package/dist/cli.js +8 -0
- package/dist/zen-build.js +482 -1802
- package/dist/zen-dev.js +482 -1802
- package/dist/zen-preview.js +482 -1802
- package/dist/zenith.js +482 -1802
- package/package.json +11 -3
- package/runtime/bundle-generator.ts +10 -1
- package/runtime/client-runtime.ts +462 -120
- package/cli/utils/content.ts +0 -112
- package/router/manifest.ts +0 -314
- package/router/navigation/ZenLink.zen +0 -231
- package/router/navigation/index.ts +0 -78
- package/router/navigation/zen-link.ts +0 -584
- package/router/runtime.ts +0 -458
- package/router/types.ts +0 -168
package/cli/commands/dev.ts
CHANGED
|
@@ -1,3 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zenith/cli - Dev Command
|
|
3
|
+
*
|
|
4
|
+
* Development server with HMR support.
|
|
5
|
+
*
|
|
6
|
+
* ═══════════════════════════════════════════════════════════════════════════════
|
|
7
|
+
* CLI HARDENING: BLIND ORCHESTRATOR PATTERN
|
|
8
|
+
* ═══════════════════════════════════════════════════════════════════════════════
|
|
9
|
+
*
|
|
10
|
+
* This file follows the CLI Hardening Plan:
|
|
11
|
+
* - NO plugin-specific branching (no `if (hasContentPlugin)`)
|
|
12
|
+
* - NO semantic helpers (no `getContentData()`)
|
|
13
|
+
* - NO plugin type imports or casts
|
|
14
|
+
* - ONLY opaque data forwarding via hooks
|
|
15
|
+
*
|
|
16
|
+
* The CLI dispatches lifecycle hooks and collects payloads.
|
|
17
|
+
* It never understands what the data means.
|
|
18
|
+
* ═══════════════════════════════════════════════════════════════════════════════
|
|
19
|
+
*/
|
|
20
|
+
|
|
1
21
|
import path from 'path'
|
|
2
22
|
import fs from 'fs'
|
|
3
23
|
import { serve, type ServerWebSocket } from 'bun'
|
|
@@ -7,13 +27,19 @@ import * as brand from '../utils/branding'
|
|
|
7
27
|
import { compileZenSource } from '../../compiler/index'
|
|
8
28
|
import { discoverLayouts } from '../../compiler/discovery/layouts'
|
|
9
29
|
import { processLayout } from '../../compiler/transform/layoutProcessor'
|
|
10
|
-
import { generateRouteDefinition } from '
|
|
30
|
+
import { generateRouteDefinition } from '@zenithbuild/router'
|
|
11
31
|
import { generateBundleJS } from '../../runtime/bundle-generator'
|
|
12
|
-
import { loadContent } from '../utils/content'
|
|
13
32
|
import { loadZenithConfig } from '../../core/config/loader'
|
|
14
|
-
import { PluginRegistry, createPluginContext } from '../../core/plugins/registry'
|
|
15
|
-
import type { ContentItem } from '../../core/config/types'
|
|
33
|
+
import { PluginRegistry, createPluginContext, getPluginDataByNamespace } from '../../core/plugins/registry'
|
|
16
34
|
import { compileCssAsync, resolveGlobalsCss } from '../../compiler/css'
|
|
35
|
+
import {
|
|
36
|
+
createBridgeAPI,
|
|
37
|
+
runPluginHooks,
|
|
38
|
+
collectHookReturns,
|
|
39
|
+
buildRuntimeEnvelope,
|
|
40
|
+
clearHooks,
|
|
41
|
+
type HookContext
|
|
42
|
+
} from '../../core/plugins/bridge'
|
|
17
43
|
|
|
18
44
|
export interface DevOptions {
|
|
19
45
|
port?: number
|
|
@@ -83,39 +109,48 @@ export async function dev(options: DevOptions = {}): Promise<void> {
|
|
|
83
109
|
const port = options.port || parseInt(process.env.PORT || '3000', 10)
|
|
84
110
|
const pagesDir = project.pagesDir
|
|
85
111
|
const rootDir = project.root
|
|
86
|
-
const contentDir = path.join(rootDir, 'content')
|
|
87
112
|
|
|
88
113
|
// Load zenith.config.ts if present
|
|
89
114
|
const config = await loadZenithConfig(rootDir)
|
|
90
115
|
const registry = new PluginRegistry()
|
|
116
|
+
const bridgeAPI = createBridgeAPI()
|
|
117
|
+
|
|
118
|
+
// Clear any previously registered hooks (important for restarts)
|
|
119
|
+
clearHooks()
|
|
91
120
|
|
|
92
121
|
console.log('[Zenith] Config plugins:', config.plugins?.length ?? 0)
|
|
93
122
|
|
|
94
|
-
//
|
|
123
|
+
// ============================================
|
|
124
|
+
// Plugin Registration (Unconditional)
|
|
125
|
+
// ============================================
|
|
126
|
+
// CLI registers ALL plugins without checking which ones exist.
|
|
127
|
+
// Each plugin decides what hooks to register.
|
|
95
128
|
for (const plugin of config.plugins || []) {
|
|
96
129
|
console.log('[Zenith] Registering plugin:', plugin.name)
|
|
97
130
|
registry.register(plugin)
|
|
131
|
+
|
|
132
|
+
// Let plugin register its CLI hooks (if it wants to)
|
|
133
|
+
// CLI does NOT check what the plugin is - it just offers the API
|
|
134
|
+
if (plugin.registerCLI) {
|
|
135
|
+
plugin.registerCLI(bridgeAPI)
|
|
136
|
+
}
|
|
98
137
|
}
|
|
99
138
|
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
// Initialize plugins
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
}))
|
|
112
|
-
} else {
|
|
113
|
-
// Fallback to legacy content loading if no content plugin configured
|
|
114
|
-
console.log('[Zenith] Using legacy content loading from:', contentDir)
|
|
115
|
-
contentData = loadContent(contentDir)
|
|
139
|
+
// ============================================
|
|
140
|
+
// Plugin Initialization (Unconditional)
|
|
141
|
+
// ============================================
|
|
142
|
+
// Initialize ALL plugins unconditionally.
|
|
143
|
+
// If no plugins, this is a no-op. CLI doesn't branch on plugin presence.
|
|
144
|
+
await registry.initAll(createPluginContext(rootDir))
|
|
145
|
+
|
|
146
|
+
// Create hook context - CLI provides this but NEVER uses getPluginData itself
|
|
147
|
+
const hookCtx: HookContext = {
|
|
148
|
+
projectRoot: rootDir,
|
|
149
|
+
getPluginData: getPluginDataByNamespace
|
|
116
150
|
}
|
|
117
151
|
|
|
118
|
-
|
|
152
|
+
// Dispatch lifecycle hook - plugins decide if they care
|
|
153
|
+
await runPluginHooks('cli:dev:start', hookCtx)
|
|
119
154
|
|
|
120
155
|
// ============================================
|
|
121
156
|
// CSS Compilation (Compiler-Owned)
|
|
@@ -189,10 +224,50 @@ export async function dev(options: DevOptions = {}): Promise<void> {
|
|
|
189
224
|
}
|
|
190
225
|
}
|
|
191
226
|
|
|
192
|
-
|
|
227
|
+
/**
|
|
228
|
+
* Generate dev HTML with plugin data envelope
|
|
229
|
+
*
|
|
230
|
+
* CLI collects payloads from plugins via 'cli:runtime:collect' hook.
|
|
231
|
+
* It serializes blindly - never inspecting what's inside.
|
|
232
|
+
*/
|
|
233
|
+
async function generateDevHTML(page: CompiledPage): Promise<string> {
|
|
234
|
+
// Collect runtime payloads from ALL plugins
|
|
235
|
+
// CLI doesn't know which plugins will respond - it just collects
|
|
236
|
+
const payloads = await collectHookReturns('cli:runtime:collect', hookCtx)
|
|
237
|
+
|
|
238
|
+
// Build envelope - CLI doesn't know what's inside
|
|
239
|
+
const envelope = buildRuntimeEnvelope(payloads)
|
|
240
|
+
|
|
241
|
+
// Escape </script> sequences in JSON to prevent breaking the script tag
|
|
242
|
+
const envelopeJson = JSON.stringify(envelope).replace(/<\//g, '<\\/')
|
|
243
|
+
|
|
244
|
+
// Single neutral injection point - NOT plugin-specific
|
|
245
|
+
const runtimeTag = `<script src="/runtime.js"></script>`
|
|
246
|
+
const pluginDataTag = `<script>window.__ZENITH_PLUGIN_DATA__ = ${envelopeJson};</script>`
|
|
247
|
+
const scriptTag = `<script type="module">\n${page.script}\n</script>`
|
|
248
|
+
const allScripts = `${runtimeTag}\n${pluginDataTag}\n${scriptTag}`
|
|
249
|
+
|
|
250
|
+
return page.html.includes('</body>')
|
|
251
|
+
? page.html.replace('</body>', `${allScripts}\n</body>`)
|
|
252
|
+
: `${page.html}\n${allScripts}`
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ============================================
|
|
256
|
+
// File Watcher (Plugin-Agnostic)
|
|
257
|
+
// ============================================
|
|
258
|
+
// CLI watches files but delegates decisions to plugins via hooks.
|
|
259
|
+
// No branching on file types that are "content" vs "not content".
|
|
193
260
|
const watcher = fs.watch(path.join(pagesDir, '..'), { recursive: true }, async (event, filename) => {
|
|
194
261
|
if (!filename) return
|
|
195
262
|
|
|
263
|
+
// Dispatch file change hook to ALL plugins
|
|
264
|
+
// Each plugin decides if it cares about this file
|
|
265
|
+
await runPluginHooks('cli:dev:file-change', {
|
|
266
|
+
...hookCtx,
|
|
267
|
+
filename,
|
|
268
|
+
event
|
|
269
|
+
})
|
|
270
|
+
|
|
196
271
|
if (filename.endsWith('.zen')) {
|
|
197
272
|
logger.hmr('Page', filename)
|
|
198
273
|
|
|
@@ -223,16 +298,13 @@ export async function dev(options: DevOptions = {}): Promise<void> {
|
|
|
223
298
|
for (const client of clients) {
|
|
224
299
|
client.send(JSON.stringify({ type: 'style-update', url: '/assets/styles.css' }))
|
|
225
300
|
}
|
|
226
|
-
} else
|
|
227
|
-
|
|
228
|
-
//
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
} else {
|
|
234
|
-
contentData = loadContent(contentDir)
|
|
235
|
-
}
|
|
301
|
+
} else {
|
|
302
|
+
// For all other file changes, re-initialize plugins unconditionally
|
|
303
|
+
// Plugins decide internally whether they need to reload data
|
|
304
|
+
// CLI does NOT branch on "is this a content file"
|
|
305
|
+
await registry.initAll(createPluginContext(rootDir))
|
|
306
|
+
|
|
307
|
+
// Broadcast reload for any non-code file changes
|
|
236
308
|
for (const client of clients) {
|
|
237
309
|
client.send(JSON.stringify({ type: 'reload' }))
|
|
238
310
|
}
|
|
@@ -305,7 +377,7 @@ export async function dev(options: DevOptions = {}): Promise<void> {
|
|
|
305
377
|
|
|
306
378
|
if (cached) {
|
|
307
379
|
const renderStart = performance.now()
|
|
308
|
-
const html = generateDevHTML(cached
|
|
380
|
+
const html = await generateDevHTML(cached)
|
|
309
381
|
const renderEnd = performance.now()
|
|
310
382
|
|
|
311
383
|
const totalTime = Math.round(performance.now() - startTime)
|
|
@@ -373,16 +445,3 @@ function findPageForRoute(route: string, pagesDir: string): string | null {
|
|
|
373
445
|
|
|
374
446
|
return null
|
|
375
447
|
}
|
|
376
|
-
|
|
377
|
-
function generateDevHTML(page: CompiledPage, contentData: any = {}): string {
|
|
378
|
-
const runtimeTag = `<script src="/runtime.js"></script>`
|
|
379
|
-
// Escape </script> sequences in JSON content to prevent breaking the script tag
|
|
380
|
-
const contentJson = JSON.stringify(contentData).replace(/<\//g, '<\\/')
|
|
381
|
-
const contentTag = `<script>window.__ZENITH_CONTENT__ = ${contentJson};</script>`
|
|
382
|
-
// Use type="module" to support ES6 imports from npm packages
|
|
383
|
-
const scriptTag = `<script type="module">\n${page.script}\n</script>`
|
|
384
|
-
const allScripts = `${runtimeTag}\n${contentTag}\n${scriptTag}`
|
|
385
|
-
return page.html.includes('</body>')
|
|
386
|
-
? page.html.replace('</body>', `${allScripts}\n</body>`)
|
|
387
|
-
: `${page.html}\n${allScripts}`
|
|
388
|
-
}
|
|
@@ -1,14 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Component Discovery
|
|
3
3
|
*
|
|
4
|
-
* Discovers and catalogs components in a Zenith project
|
|
5
|
-
*
|
|
4
|
+
* Discovers and catalogs components in a Zenith project.
|
|
5
|
+
* Components are auto-imported based on filename:
|
|
6
|
+
*
|
|
7
|
+
* Auto-Import Rules:
|
|
8
|
+
* - Component name = filename (without .zen extension)
|
|
9
|
+
* - Subdirectories are for organization, not namespacing
|
|
10
|
+
* - Name collisions produce compile-time errors with clear messages
|
|
11
|
+
*
|
|
12
|
+
* Examples:
|
|
13
|
+
* components/Header.zen → <Header />
|
|
14
|
+
* components/sections/HeroSection.zen → <HeroSection />
|
|
15
|
+
* components/ui/buttons/Primary.zen → <Primary />
|
|
16
|
+
*
|
|
17
|
+
* If you have name collisions (same filename in different directories),
|
|
18
|
+
* you must rename one of the components.
|
|
19
|
+
*
|
|
20
|
+
* Requirements:
|
|
21
|
+
* - Auto-import resolution is deterministic and compile-time only
|
|
22
|
+
* - Name collisions produce compile-time errors with clear messages
|
|
23
|
+
* - No runtime component registration or global singleton registries
|
|
6
24
|
*/
|
|
7
25
|
|
|
8
26
|
import * as fs from 'fs'
|
|
9
27
|
import * as path from 'path'
|
|
10
28
|
import { parseZenFile } from '../parse/parseZenFile'
|
|
11
|
-
import
|
|
29
|
+
import { CompilerError } from '../errors/compilerError'
|
|
30
|
+
import type { TemplateNode, ExpressionIR } from '../ir/types'
|
|
12
31
|
|
|
13
32
|
export interface SlotDefinition {
|
|
14
33
|
name: string | null // null = default slot, string = named slot
|
|
@@ -19,10 +38,12 @@ export interface SlotDefinition {
|
|
|
19
38
|
}
|
|
20
39
|
|
|
21
40
|
export interface ComponentMetadata {
|
|
22
|
-
name: string // Component name (e.g., "Card", "
|
|
41
|
+
name: string // Component name (e.g., "Card", "HeroSection")
|
|
23
42
|
path: string // Absolute path to .zen file
|
|
43
|
+
relativePath: string // Relative path from components directory
|
|
24
44
|
template: string // Raw template HTML
|
|
25
45
|
nodes: TemplateNode[] // Parsed template nodes
|
|
46
|
+
expressions: ExpressionIR[] // Expressions referenced by nodes
|
|
26
47
|
slots: SlotDefinition[]
|
|
27
48
|
props: string[] // Declared props
|
|
28
49
|
styles: string[] // Raw CSS from <style> blocks
|
|
@@ -33,12 +54,18 @@ export interface ComponentMetadata {
|
|
|
33
54
|
}
|
|
34
55
|
|
|
35
56
|
/**
|
|
36
|
-
* Discover all components in a directory
|
|
57
|
+
* Discover all components in a directory with auto-import naming
|
|
58
|
+
*
|
|
59
|
+
* Components are named by their filename (without .zen extension).
|
|
60
|
+
* Subdirectories are for organization only and do not affect the component name.
|
|
61
|
+
*
|
|
37
62
|
* @param baseDir - Base directory to search (e.g., src/components)
|
|
38
63
|
* @returns Map of component name to metadata
|
|
64
|
+
* @throws CompilerError on name collisions
|
|
39
65
|
*/
|
|
40
66
|
export function discoverComponents(baseDir: string): Map<string, ComponentMetadata> {
|
|
41
67
|
const components = new Map<string, ComponentMetadata>()
|
|
68
|
+
const collisions = new Map<string, string[]>() // name → [relative paths]
|
|
42
69
|
|
|
43
70
|
// Check if components directory exists
|
|
44
71
|
if (!fs.existsSync(baseDir)) {
|
|
@@ -50,15 +77,43 @@ export function discoverComponents(baseDir: string): Map<string, ComponentMetada
|
|
|
50
77
|
|
|
51
78
|
for (const filePath of zenFiles) {
|
|
52
79
|
try {
|
|
53
|
-
const metadata = parseComponentFile(filePath)
|
|
80
|
+
const metadata = parseComponentFile(filePath, baseDir)
|
|
54
81
|
if (metadata) {
|
|
55
|
-
|
|
82
|
+
// Check for collision
|
|
83
|
+
if (components.has(metadata.name)) {
|
|
84
|
+
const existing = components.get(metadata.name)!
|
|
85
|
+
if (!collisions.has(metadata.name)) {
|
|
86
|
+
collisions.set(metadata.name, [existing.relativePath])
|
|
87
|
+
}
|
|
88
|
+
collisions.get(metadata.name)!.push(metadata.relativePath)
|
|
89
|
+
} else {
|
|
90
|
+
components.set(metadata.name, metadata)
|
|
91
|
+
}
|
|
56
92
|
}
|
|
57
93
|
} catch (error: any) {
|
|
58
94
|
console.warn(`[Zenith] Failed to parse component ${filePath}: ${error.message}`)
|
|
59
95
|
}
|
|
60
96
|
}
|
|
61
97
|
|
|
98
|
+
// Report all collisions as a single error
|
|
99
|
+
if (collisions.size > 0) {
|
|
100
|
+
const collisionMessages = Array.from(collisions.entries())
|
|
101
|
+
.map(([name, paths]) => {
|
|
102
|
+
const pathList = paths.map(p => ` - ${p}`).join('\n')
|
|
103
|
+
return `Component name "${name}" is used by multiple files:\n${pathList}`
|
|
104
|
+
})
|
|
105
|
+
.join('\n\n')
|
|
106
|
+
|
|
107
|
+
throw new CompilerError(
|
|
108
|
+
`Component name collision detected!\n\n${collisionMessages}\n\n` +
|
|
109
|
+
`Each component must have a unique filename.\n` +
|
|
110
|
+
`To fix: Rename one of the conflicting components to have a unique name.`,
|
|
111
|
+
baseDir,
|
|
112
|
+
0,
|
|
113
|
+
0
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
62
117
|
return components
|
|
63
118
|
}
|
|
64
119
|
|
|
@@ -89,13 +144,20 @@ function findZenFiles(dir: string): string[] {
|
|
|
89
144
|
|
|
90
145
|
/**
|
|
91
146
|
* Parse a component file and extract metadata
|
|
147
|
+
*
|
|
148
|
+
* Component name is derived from the filename (without .zen extension).
|
|
149
|
+
*
|
|
150
|
+
* @param filePath - Absolute path to the component file
|
|
151
|
+
* @param baseDir - Base directory for component discovery (used for relative path)
|
|
92
152
|
*/
|
|
93
|
-
function parseComponentFile(filePath: string): ComponentMetadata | null {
|
|
153
|
+
function parseComponentFile(filePath: string, baseDir: string): ComponentMetadata | null {
|
|
94
154
|
const ir = parseZenFile(filePath)
|
|
95
155
|
|
|
96
|
-
//
|
|
97
|
-
const
|
|
98
|
-
|
|
156
|
+
// Component name is just the filename (without .zen extension)
|
|
157
|
+
const componentName = path.basename(filePath, '.zen')
|
|
158
|
+
|
|
159
|
+
// Relative path for error messages and debugging
|
|
160
|
+
const relativePath = path.relative(baseDir, filePath)
|
|
99
161
|
|
|
100
162
|
// Extract slots from template
|
|
101
163
|
const slots = extractSlots(ir.template.nodes)
|
|
@@ -109,8 +171,10 @@ function parseComponentFile(filePath: string): ComponentMetadata | null {
|
|
|
109
171
|
return {
|
|
110
172
|
name: componentName,
|
|
111
173
|
path: filePath,
|
|
174
|
+
relativePath,
|
|
112
175
|
template: ir.template.raw,
|
|
113
176
|
nodes: ir.template.nodes,
|
|
177
|
+
expressions: ir.template.expressions, // Store expressions for later merging
|
|
114
178
|
slots,
|
|
115
179
|
props,
|
|
116
180
|
styles,
|
package/compiler/output/types.ts
CHANGED
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
* Compiled Template Output Types
|
|
3
3
|
*
|
|
4
4
|
* Phase 2: Transform IR → Static HTML + Runtime Bindings
|
|
5
|
+
* Phase 8: Extended with fragment binding types (loop, conditional, optional)
|
|
5
6
|
*/
|
|
6
7
|
|
|
8
|
+
import type { TemplateNode } from '../ir/types'
|
|
9
|
+
|
|
7
10
|
export type CompiledTemplate = {
|
|
8
11
|
html: string
|
|
9
12
|
bindings: Binding[]
|
|
@@ -13,7 +16,7 @@ export type CompiledTemplate = {
|
|
|
13
16
|
|
|
14
17
|
export type Binding = {
|
|
15
18
|
id: string
|
|
16
|
-
type: 'text' | 'attribute'
|
|
19
|
+
type: 'text' | 'attribute' | 'loop' | 'conditional' | 'optional'
|
|
17
20
|
target: string // e.g., "data-zen-text" or "class" for attribute bindings
|
|
18
21
|
expression: string // The original expression code
|
|
19
22
|
location?: {
|
|
@@ -21,6 +24,17 @@ export type Binding = {
|
|
|
21
24
|
column: number
|
|
22
25
|
}
|
|
23
26
|
loopContext?: LoopContext // Phase 7: Loop context for expressions inside map iterations
|
|
27
|
+
loopMeta?: LoopMeta // Phase 8: Metadata for loop bindings
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Loop binding metadata
|
|
32
|
+
* Phase 8: Contains loop variable names and body template for runtime instantiation
|
|
33
|
+
*/
|
|
34
|
+
export type LoopMeta = {
|
|
35
|
+
itemVar: string
|
|
36
|
+
indexVar?: string
|
|
37
|
+
bodyTemplate: TemplateNode[]
|
|
24
38
|
}
|
|
25
39
|
|
|
26
40
|
/**
|
|
@@ -548,6 +548,31 @@ function parseNode(
|
|
|
548
548
|
return null
|
|
549
549
|
}
|
|
550
550
|
|
|
551
|
+
/**
|
|
552
|
+
* Convert self-closing component tags to properly closed tags
|
|
553
|
+
*
|
|
554
|
+
* HTML5/parse5 treats `<ComponentName />` as an opening tag (the `/` is ignored),
|
|
555
|
+
* which causes following siblings to be incorrectly nested as children.
|
|
556
|
+
*
|
|
557
|
+
* This function converts `<ComponentName />` to `<ComponentName></ComponentName>`
|
|
558
|
+
* for tags that start with uppercase (Zenith components).
|
|
559
|
+
*
|
|
560
|
+
* Example:
|
|
561
|
+
* Input: `<Header /><Hero /><Footer />`
|
|
562
|
+
* Output: `<Header></Header><Hero></Hero><Footer></Footer>`
|
|
563
|
+
*/
|
|
564
|
+
function convertSelfClosingComponents(html: string): string {
|
|
565
|
+
// Match self-closing tags that start with uppercase (component tags)
|
|
566
|
+
// Pattern: <ComponentName ... />
|
|
567
|
+
// Captures: ComponentName and any attributes
|
|
568
|
+
const selfClosingPattern = /<([A-Z][a-zA-Z0-9._-]*)([^>]*?)\/>/g
|
|
569
|
+
|
|
570
|
+
return html.replace(selfClosingPattern, (match, tagName, attributes) => {
|
|
571
|
+
// Convert to properly closed tag
|
|
572
|
+
return `<${tagName}${attributes}></${tagName}>`
|
|
573
|
+
})
|
|
574
|
+
}
|
|
575
|
+
|
|
551
576
|
/**
|
|
552
577
|
* Parse template from HTML string
|
|
553
578
|
*/
|
|
@@ -555,6 +580,10 @@ export function parseTemplate(html: string, filePath: string): TemplateIR {
|
|
|
555
580
|
// Strip script and style blocks
|
|
556
581
|
let templateHtml = stripBlocks(html)
|
|
557
582
|
|
|
583
|
+
// Convert self-closing component tags to properly closed tags
|
|
584
|
+
// This fixes the component stacking bug where siblings become nested children
|
|
585
|
+
templateHtml = convertSelfClosingComponents(templateHtml)
|
|
586
|
+
|
|
558
587
|
// Normalize all expressions so parse5 can parse them safely
|
|
559
588
|
const { normalized, expressions: normalizedExprs } = normalizeAllExpressions(templateHtml)
|
|
560
589
|
templateHtml = normalized
|
|
@@ -259,10 +259,10 @@ export function generateExplicitExpressionWrapper(
|
|
|
259
259
|
contextParts.push('state')
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
-
// Create merged context
|
|
262
|
+
// Create merged context code
|
|
263
263
|
const contextCode = contextParts.length > 0
|
|
264
|
-
? `
|
|
265
|
-
: '
|
|
264
|
+
? `var __ctx = Object.assign({}, ${contextParts.join(', ')});`
|
|
265
|
+
: 'var __ctx = state || {};'
|
|
266
266
|
|
|
267
267
|
// Escape the code for use in a single-line comment (replace newlines with spaces)
|
|
268
268
|
const commentCode = code.replace(/[\r\n]+/g, ' ').replace(/\s+/g, ' ').substring(0, 100)
|
|
@@ -273,6 +273,13 @@ export function generateExplicitExpressionWrapper(
|
|
|
273
273
|
// Transform JSX
|
|
274
274
|
const transformedCode = transformExpressionJSX(code)
|
|
275
275
|
|
|
276
|
+
// Properly escape the transformed code for use inside a string
|
|
277
|
+
const escapedTransformedCode = transformedCode
|
|
278
|
+
.replace(/\\/g, '\\\\')
|
|
279
|
+
.replace(/'/g, "\\'")
|
|
280
|
+
.replace(/\n/g, '\\n')
|
|
281
|
+
.replace(/\r/g, '\\r')
|
|
282
|
+
|
|
276
283
|
return `
|
|
277
284
|
// Expression: ${commentCode}${code.length > 100 ? '...' : ''}
|
|
278
285
|
// Dependencies: ${JSON.stringify({
|
|
@@ -281,16 +288,24 @@ export function generateExplicitExpressionWrapper(
|
|
|
281
288
|
stores: dependencies.usesStores,
|
|
282
289
|
state: dependencies.usesState
|
|
283
290
|
})}
|
|
284
|
-
const ${id} = (
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
291
|
+
const ${id} = (function() {
|
|
292
|
+
// Create the evaluator function once (with 'with' support in sloppy mode)
|
|
293
|
+
var evalFn = new Function('__ctx',
|
|
294
|
+
'with (__ctx) { return (' + '${escapedTransformedCode}' + '); }'
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
return function(${paramList}) {
|
|
298
|
+
try {
|
|
299
|
+
// Merge window globals with context (for script-level variables)
|
|
300
|
+
var __baseCtx = Object.assign({}, window);
|
|
301
|
+
${contextCode.replace('var __ctx', 'var __ctx').replace('= Object.assign({},', '= Object.assign(__baseCtx,')}
|
|
302
|
+
return evalFn(__ctx);
|
|
303
|
+
} catch (e) {
|
|
304
|
+
console.warn('[Zenith] Expression evaluation error:', ${jsonEscapedCode}, e);
|
|
305
|
+
return undefined;
|
|
288
306
|
}
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
return undefined;
|
|
292
|
-
}
|
|
293
|
-
};`
|
|
307
|
+
};
|
|
308
|
+
})();`
|
|
294
309
|
}
|
|
295
310
|
|
|
296
311
|
/**
|
|
@@ -119,7 +119,10 @@ ${indent}}\n`
|
|
|
119
119
|
const conditionId = `cond_${varCounter.count++}`
|
|
120
120
|
|
|
121
121
|
let code = `${indent}const ${containerVar} = document.createDocumentFragment();\n`
|
|
122
|
-
|
|
122
|
+
// Evaluate condition with state context using new Function (sloppy mode allows 'with')
|
|
123
|
+
const escapedCondition = condNode.condition.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
|
|
124
|
+
code += `${indent}const ${conditionId}_evalFn = new Function('state', 'with (state) { return (' + '${escapedCondition}' + '); }');\n`
|
|
125
|
+
code += `${indent}const ${conditionId}_result = (function() { try { return ${conditionId}_evalFn(state); } catch(e) { return false; } })();\n`
|
|
123
126
|
|
|
124
127
|
// Generate consequent branch
|
|
125
128
|
code += `${indent}if (${conditionId}_result) {\n`
|
|
@@ -149,7 +152,10 @@ ${indent}}\n`
|
|
|
149
152
|
const conditionId = `opt_${varCounter.count++}`
|
|
150
153
|
|
|
151
154
|
let code = `${indent}const ${containerVar} = document.createDocumentFragment();\n`
|
|
152
|
-
|
|
155
|
+
// Evaluate condition with state context using new Function (sloppy mode allows 'with')
|
|
156
|
+
const escapedCondition = optNode.condition.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
|
|
157
|
+
code += `${indent}const ${conditionId}_evalFn = new Function('state', 'with (state) { return (' + '${escapedCondition}' + '); }');\n`
|
|
158
|
+
code += `${indent}const ${conditionId}_result = (function() { try { return ${conditionId}_evalFn(state); } catch(e) { return false; } })();\n`
|
|
153
159
|
code += `${indent}if (${conditionId}_result) {\n`
|
|
154
160
|
|
|
155
161
|
for (const child of optNode.fragment) {
|
|
@@ -171,7 +177,10 @@ ${indent}}\n`
|
|
|
171
177
|
const loopId = `loop_${varCounter.count++}`
|
|
172
178
|
|
|
173
179
|
let code = `${indent}const ${containerVar} = document.createDocumentFragment();\n`
|
|
174
|
-
|
|
180
|
+
// Evaluate loop source with state context using new Function (sloppy mode allows 'with')
|
|
181
|
+
const escapedSource = loopNode.source.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
|
|
182
|
+
code += `${indent}const ${loopId}_evalFn = new Function('state', 'with (state) { return (' + '${escapedSource}' + '); }');\n`
|
|
183
|
+
code += `${indent}const ${loopId}_items = (function() { try { return ${loopId}_evalFn(state); } catch(e) { return []; } })() || [];\n`
|
|
175
184
|
|
|
176
185
|
// Loop parameters
|
|
177
186
|
const itemVar = loopNode.itemVar
|
|
@@ -115,6 +115,9 @@ function generateRuntimeBundle(parts: {
|
|
|
115
115
|
// Extract function declarations from script code to register on window
|
|
116
116
|
const functionRegistrations = extractFunctionRegistrations(parts.scriptCode)
|
|
117
117
|
|
|
118
|
+
// Extract const/let declarations from script code to register on window
|
|
119
|
+
const variableRegistrations = extractVariableRegistrations(parts.scriptCode)
|
|
120
|
+
|
|
118
121
|
// Generate npm imports header (hoisted, deduplicated, deterministic)
|
|
119
122
|
const npmImportsHeader = parts.npmImports.length > 0
|
|
120
123
|
? `// NPM Imports (hoisted from component scripts)\n${emitImports(parts.npmImports)}\n\n`
|
|
@@ -139,6 +142,8 @@ ${parts.scriptCode ? parts.scriptCode : ''}
|
|
|
139
142
|
|
|
140
143
|
${functionRegistrations}
|
|
141
144
|
|
|
145
|
+
${variableRegistrations}
|
|
146
|
+
|
|
142
147
|
${parts.stateInitCode ? `// State initialization
|
|
143
148
|
${parts.stateInitCode}` : ''}
|
|
144
149
|
|
|
@@ -260,6 +265,37 @@ function extractFunctionRegistrations(scriptCode: string): string {
|
|
|
260
265
|
return `// Register functions on window for event handlers\n${registrations}`
|
|
261
266
|
}
|
|
262
267
|
|
|
268
|
+
/**
|
|
269
|
+
* Extract const/let declarations and generate window registration code
|
|
270
|
+
* This allows expressions evaluated via new Function() to access script-level variables
|
|
271
|
+
*/
|
|
272
|
+
function extractVariableRegistrations(scriptCode: string): string {
|
|
273
|
+
if (!scriptCode) return ''
|
|
274
|
+
|
|
275
|
+
// Match const/let declarations: const name = ... or let name = ...
|
|
276
|
+
// Also match destructured: const { a, b } = ... but we only care about simple assignments
|
|
277
|
+
const varPattern = /(?:const|let)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=/g
|
|
278
|
+
const varNames: string[] = []
|
|
279
|
+
let match
|
|
280
|
+
|
|
281
|
+
while ((match = varPattern.exec(scriptCode)) !== null) {
|
|
282
|
+
if (match[1]) {
|
|
283
|
+
varNames.push(match[1])
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (varNames.length === 0) {
|
|
288
|
+
return ''
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Generate window registration for each variable
|
|
292
|
+
const registrations = varNames.map(name =>
|
|
293
|
+
` if (typeof ${name} !== 'undefined') window.${name} = ${name};`
|
|
294
|
+
).join('\n')
|
|
295
|
+
|
|
296
|
+
return `// Register script variables on window for expression access\n${registrations}`
|
|
297
|
+
}
|
|
298
|
+
|
|
263
299
|
/**
|
|
264
300
|
* Generate hydrate function that mounts the DOM with reactivity
|
|
265
301
|
*/
|
|
@@ -44,22 +44,41 @@ export function wrapExpression(
|
|
|
44
44
|
const commentCode = code.replace(/[\r\n]+/g, ' ').replace(/\s+/g, ' ').substring(0, 100)
|
|
45
45
|
const jsonEscapedCode = JSON.stringify(code)
|
|
46
46
|
|
|
47
|
+
// Properly escape the transformed code for use inside a string
|
|
48
|
+
const escapedTransformedCode = transformedCode
|
|
49
|
+
.replace(/\\/g, '\\\\')
|
|
50
|
+
.replace(/'/g, "\\'")
|
|
51
|
+
.replace(/\n/g, '\\n')
|
|
52
|
+
.replace(/\r/g, '\\r')
|
|
53
|
+
|
|
54
|
+
// Note: We cannot use `with (state)` in ES modules (strict mode)
|
|
55
|
+
// Instead, we use new Function() which runs in non-strict sloppy mode by default
|
|
56
|
+
// and allows 'with' statements. This is a workaround for strict mode limitations.
|
|
47
57
|
return `
|
|
48
58
|
// Expression: ${commentCode}${code.length > 100 ? '...' : ''}
|
|
49
|
-
const ${id} = (
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
59
|
+
const ${id} = (function() {
|
|
60
|
+
// Create the evaluator function once (with 'with' support in sloppy mode)
|
|
61
|
+
var evalFn = new Function('__ctx',
|
|
62
|
+
'with (__ctx) { return (' + '${escapedTransformedCode}' + '); }'
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
return function(state) {
|
|
66
|
+
try {
|
|
67
|
+
var __zenith = window.__zenith || {};
|
|
68
|
+
var zenCollection = __zenith.zenCollection || function(name) { return { get: function() { return []; } }; };
|
|
69
|
+
var createZenOrder = __zenith.createZenOrder || function(sections) { return { sections: [], getSectionBySlug: function() { return null; }, getDocBySlug: function() { return null; } }; };
|
|
70
|
+
|
|
71
|
+
// Merge window globals (script variables) with state
|
|
72
|
+
// State takes precedence over window globals
|
|
73
|
+
var __ctx = Object.assign({}, window, { zenCollection: zenCollection, createZenOrder: createZenOrder }, state || {});
|
|
74
|
+
|
|
75
|
+
return evalFn(__ctx);
|
|
76
|
+
} catch (e) {
|
|
77
|
+
console.warn('[Zenith] Expression evaluation error:', ${jsonEscapedCode}, e);
|
|
78
|
+
return undefined;
|
|
57
79
|
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
return undefined;
|
|
61
|
-
}
|
|
62
|
-
};`
|
|
80
|
+
};
|
|
81
|
+
})();`
|
|
63
82
|
}
|
|
64
83
|
|
|
65
84
|
/**
|