pywire 0.1.1__py3-none-any.whl → 0.1.2__py3-none-any.whl
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.
- pywire/__init__.py +2 -0
- pywire/cli/__init__.py +1 -0
- pywire/cli/generators.py +48 -0
- pywire/cli/main.py +309 -0
- pywire/cli/tui.py +563 -0
- pywire/cli/validate.py +26 -0
- pywire/client/.prettierignore +8 -0
- pywire/client/.prettierrc +7 -0
- pywire/client/build.mjs +73 -0
- pywire/client/eslint.config.js +46 -0
- pywire/client/package.json +39 -0
- pywire/client/pnpm-lock.yaml +2971 -0
- pywire/client/src/core/app.ts +263 -0
- pywire/client/src/core/dom-updater.test.ts +78 -0
- pywire/client/src/core/dom-updater.ts +321 -0
- pywire/client/src/core/index.ts +5 -0
- pywire/client/src/core/transport-manager.test.ts +179 -0
- pywire/client/src/core/transport-manager.ts +159 -0
- pywire/client/src/core/transports/base.ts +122 -0
- pywire/client/src/core/transports/http.ts +142 -0
- pywire/client/src/core/transports/index.ts +13 -0
- pywire/client/src/core/transports/websocket.ts +97 -0
- pywire/client/src/core/transports/webtransport.ts +149 -0
- pywire/client/src/dev/dev-app.ts +93 -0
- pywire/client/src/dev/error-trace.test.ts +97 -0
- pywire/client/src/dev/error-trace.ts +76 -0
- pywire/client/src/dev/index.ts +4 -0
- pywire/client/src/dev/status-overlay.ts +63 -0
- pywire/client/src/events/handler.test.ts +318 -0
- pywire/client/src/events/handler.ts +454 -0
- pywire/client/src/pywire.core.ts +22 -0
- pywire/client/src/pywire.dev.ts +27 -0
- pywire/client/tsconfig.json +17 -0
- pywire/client/vitest.config.ts +15 -0
- pywire/compiler/__init__.py +6 -0
- pywire/compiler/ast_nodes.py +304 -0
- pywire/compiler/attributes/__init__.py +6 -0
- pywire/compiler/attributes/base.py +24 -0
- pywire/compiler/attributes/conditional.py +37 -0
- pywire/compiler/attributes/events.py +55 -0
- pywire/compiler/attributes/form.py +37 -0
- pywire/compiler/attributes/loop.py +75 -0
- pywire/compiler/attributes/reactive.py +34 -0
- pywire/compiler/build.py +28 -0
- pywire/compiler/build_artifacts.py +342 -0
- pywire/compiler/codegen/__init__.py +5 -0
- pywire/compiler/codegen/attributes/__init__.py +6 -0
- pywire/compiler/codegen/attributes/base.py +19 -0
- pywire/compiler/codegen/attributes/events.py +35 -0
- pywire/compiler/codegen/directives/__init__.py +6 -0
- pywire/compiler/codegen/directives/base.py +16 -0
- pywire/compiler/codegen/directives/path.py +53 -0
- pywire/compiler/codegen/generator.py +2341 -0
- pywire/compiler/codegen/template.py +2178 -0
- pywire/compiler/directives/__init__.py +7 -0
- pywire/compiler/directives/base.py +20 -0
- pywire/compiler/directives/component.py +33 -0
- pywire/compiler/directives/context.py +93 -0
- pywire/compiler/directives/layout.py +49 -0
- pywire/compiler/directives/no_spa.py +24 -0
- pywire/compiler/directives/path.py +71 -0
- pywire/compiler/directives/props.py +88 -0
- pywire/compiler/exceptions.py +19 -0
- pywire/compiler/interpolation/__init__.py +6 -0
- pywire/compiler/interpolation/base.py +28 -0
- pywire/compiler/interpolation/jinja.py +272 -0
- pywire/compiler/parser.py +750 -0
- pywire/compiler/paths.py +29 -0
- pywire/compiler/preprocessor.py +43 -0
- pywire/core/wire.py +119 -0
- pywire/py.typed +0 -0
- pywire/runtime/__init__.py +7 -0
- pywire/runtime/aioquic_server.py +194 -0
- pywire/runtime/app.py +901 -0
- pywire/runtime/compile_error_page.py +195 -0
- pywire/runtime/debug.py +203 -0
- pywire/runtime/dev_server.py +434 -0
- pywire/runtime/dev_server.py.broken +268 -0
- pywire/runtime/error_page.py +64 -0
- pywire/runtime/error_renderer.py +23 -0
- pywire/runtime/escape.py +23 -0
- pywire/runtime/files.py +40 -0
- pywire/runtime/helpers.py +97 -0
- pywire/runtime/http_transport.py +253 -0
- pywire/runtime/loader.py +272 -0
- pywire/runtime/logging.py +72 -0
- pywire/runtime/page.py +384 -0
- pywire/runtime/pydantic_integration.py +52 -0
- pywire/runtime/router.py +229 -0
- pywire/runtime/server.py +25 -0
- pywire/runtime/style_collector.py +31 -0
- pywire/runtime/upload_manager.py +76 -0
- pywire/runtime/validation.py +449 -0
- pywire/runtime/websocket.py +665 -0
- pywire/runtime/webtransport_handler.py +195 -0
- {pywire-0.1.1.dist-info → pywire-0.1.2.dist-info}/METADATA +1 -1
- pywire-0.1.2.dist-info/RECORD +104 -0
- pywire-0.1.1.dist-info/RECORD +0 -9
- {pywire-0.1.1.dist-info → pywire-0.1.2.dist-info}/WHEEL +0 -0
- {pywire-0.1.1.dist-info → pywire-0.1.2.dist-info}/entry_points.txt +0 -0
- {pywire-0.1.1.dist-info → pywire-0.1.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { TransportManager, TransportConfig } from './transport-manager'
|
|
2
|
+
import { DOMUpdater } from './dom-updater'
|
|
3
|
+
import { ServerMessage, ClientMessage, EventData, RelocateMessage } from './transports'
|
|
4
|
+
import { UnifiedEventHandler } from '../events/handler'
|
|
5
|
+
|
|
6
|
+
export interface PyWireConfig extends TransportConfig {
|
|
7
|
+
/** Auto-initialize on DOMContentLoaded */
|
|
8
|
+
autoInit?: boolean
|
|
9
|
+
/** Enable verbose debug logging */
|
|
10
|
+
debug?: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const DEFAULT_CONFIG: PyWireConfig = {
|
|
14
|
+
autoInit: true,
|
|
15
|
+
enableWebTransport: true,
|
|
16
|
+
enableWebSocket: true,
|
|
17
|
+
enableHTTP: true,
|
|
18
|
+
debug: false,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Core PyWire Application class.
|
|
23
|
+
* Provides transport, DOM updates, SPA navigation, and event handling.
|
|
24
|
+
* Dev-only features (status overlay, error traces) are in the dev bundle.
|
|
25
|
+
*/
|
|
26
|
+
export class PyWireApp {
|
|
27
|
+
protected transport: TransportManager
|
|
28
|
+
protected updater: DOMUpdater
|
|
29
|
+
protected eventHandler: UnifiedEventHandler
|
|
30
|
+
protected initialized = false
|
|
31
|
+
protected config: PyWireConfig
|
|
32
|
+
protected siblingPaths: string[] = []
|
|
33
|
+
protected pathRegexes: RegExp[] = []
|
|
34
|
+
protected pjaxEnabled = false
|
|
35
|
+
protected isConnected = false
|
|
36
|
+
|
|
37
|
+
constructor(config: Partial<PyWireConfig> = {}) {
|
|
38
|
+
this.config = { ...DEFAULT_CONFIG, ...config }
|
|
39
|
+
this.transport = new TransportManager(this.config)
|
|
40
|
+
this.updater = new DOMUpdater(this.config.debug)
|
|
41
|
+
this.eventHandler = new UnifiedEventHandler(this)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
getConfig(): PyWireConfig {
|
|
45
|
+
return this.config
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Initialize the PyWire application.
|
|
50
|
+
*/
|
|
51
|
+
async init(): Promise<void> {
|
|
52
|
+
if (this.initialized) return
|
|
53
|
+
this.initialized = true
|
|
54
|
+
|
|
55
|
+
// Setup message handling
|
|
56
|
+
this.transport.onMessage((msg) => this.handleMessage(msg))
|
|
57
|
+
this.transport.onStatusChange((connected) => this.handleStatusChange(connected))
|
|
58
|
+
|
|
59
|
+
// Connect transport with fallback
|
|
60
|
+
try {
|
|
61
|
+
await this.transport.connect()
|
|
62
|
+
} catch (e) {
|
|
63
|
+
console.error('PyWire: Failed to connect:', e)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Load SPA metadata and setup navigation
|
|
67
|
+
this.loadSPAMetadata()
|
|
68
|
+
this.setupSPANavigation()
|
|
69
|
+
|
|
70
|
+
// Setup event interception via UnifiedEventHandler
|
|
71
|
+
this.eventHandler.init()
|
|
72
|
+
|
|
73
|
+
console.log(
|
|
74
|
+
`PyWire: Initialized (transport: ${this.transport.getActiveTransport()}, spa_paths: ${this.siblingPaths.length}, pjax: ${this.pjaxEnabled})`
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Handle connection status changes. Override in dev bundle for UI.
|
|
80
|
+
*/
|
|
81
|
+
protected handleStatusChange(connected: boolean): void {
|
|
82
|
+
this.isConnected = connected
|
|
83
|
+
// Base implementation: no UI, just track state
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Load SPA navigation metadata from injected script tag.
|
|
88
|
+
*/
|
|
89
|
+
protected loadSPAMetadata(): void {
|
|
90
|
+
const metaScript = document.getElementById('_pywire_spa_meta')
|
|
91
|
+
if (metaScript) {
|
|
92
|
+
try {
|
|
93
|
+
const meta = JSON.parse(metaScript.textContent || '{}')
|
|
94
|
+
this.siblingPaths = meta.sibling_paths || []
|
|
95
|
+
this.pjaxEnabled = !!meta.enable_pjax
|
|
96
|
+
if (meta.debug !== undefined) {
|
|
97
|
+
this.config.debug = !!meta.debug
|
|
98
|
+
}
|
|
99
|
+
// Convert path patterns to regexes for matching
|
|
100
|
+
this.pathRegexes = this.siblingPaths.map((p) => this.patternToRegex(p))
|
|
101
|
+
} catch (e) {
|
|
102
|
+
console.warn('PyWire: Failed to parse SPA metadata', e)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Convert route pattern like '/a/:id' to regex.
|
|
109
|
+
*/
|
|
110
|
+
protected patternToRegex(pattern: string): RegExp {
|
|
111
|
+
// Escape special regex chars except for our placeholders
|
|
112
|
+
let regex = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
|
|
113
|
+
// Replace :param:type or :param with capture groups
|
|
114
|
+
regex = regex.replace(/:(\\w+)(:\\w+)?/g, '([^/]+)')
|
|
115
|
+
// Replace {param:type} or {param} with capture groups
|
|
116
|
+
regex = regex.replace(/\\{(\\w+)(:\\w+)?\\}/g, '([^/]+)')
|
|
117
|
+
return new RegExp(`^${regex}$`)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Check if a path matches any sibling path pattern.
|
|
122
|
+
*/
|
|
123
|
+
protected isSiblingPath(path: string): boolean {
|
|
124
|
+
return this.pathRegexes.some((regex) => regex.test(path))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Setup SPA navigation for sibling paths.
|
|
129
|
+
*/
|
|
130
|
+
protected setupSPANavigation(): void {
|
|
131
|
+
// Handle browser back/forward
|
|
132
|
+
window.addEventListener('popstate', () => {
|
|
133
|
+
this.sendRelocate(window.location.pathname + window.location.search)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
if (this.siblingPaths.length === 0 && !this.pjaxEnabled) return
|
|
137
|
+
|
|
138
|
+
// Intercept link clicks
|
|
139
|
+
document.addEventListener('click', (e) => {
|
|
140
|
+
const link = (e.target as Element).closest('a[href]') as HTMLAnchorElement | null
|
|
141
|
+
if (!link) return
|
|
142
|
+
|
|
143
|
+
// Only intercept same-origin links
|
|
144
|
+
if (link.origin !== window.location.origin) return
|
|
145
|
+
|
|
146
|
+
// Ignore special links
|
|
147
|
+
if (link.hasAttribute('download') || link.target === '_blank') return
|
|
148
|
+
|
|
149
|
+
// Check if matches criteria
|
|
150
|
+
let shouldIntercept = false
|
|
151
|
+
|
|
152
|
+
if (this.pjaxEnabled) {
|
|
153
|
+
shouldIntercept = true
|
|
154
|
+
} else if (this.isSiblingPath(link.pathname)) {
|
|
155
|
+
shouldIntercept = true
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (shouldIntercept) {
|
|
159
|
+
e.preventDefault()
|
|
160
|
+
this.navigateTo(link.pathname + link.search)
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Navigate to a path using SPA navigation.
|
|
167
|
+
*/
|
|
168
|
+
navigateTo(path: string): void {
|
|
169
|
+
if (!this.isConnected) {
|
|
170
|
+
console.warn('PyWire: Navigation blocked - Offline')
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
history.pushState({}, '', path)
|
|
175
|
+
this.sendRelocate(path)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Send relocate message to server.
|
|
180
|
+
*/
|
|
181
|
+
protected sendRelocate(path: string): void {
|
|
182
|
+
const message: RelocateMessage = {
|
|
183
|
+
type: 'relocate',
|
|
184
|
+
path,
|
|
185
|
+
}
|
|
186
|
+
this.transport.send(message)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Send an event to the server.
|
|
191
|
+
*/
|
|
192
|
+
sendEvent(handler: string, data: EventData): void {
|
|
193
|
+
const message: ClientMessage = {
|
|
194
|
+
type: 'event',
|
|
195
|
+
handler,
|
|
196
|
+
path: window.location.pathname + window.location.search,
|
|
197
|
+
data,
|
|
198
|
+
}
|
|
199
|
+
this.transport.send(message)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Handle incoming server message. Override in dev bundle for error_trace.
|
|
204
|
+
*/
|
|
205
|
+
protected async handleMessage(msg: ServerMessage): Promise<void> {
|
|
206
|
+
switch (msg.type) {
|
|
207
|
+
case 'update':
|
|
208
|
+
if (msg.regions && msg.regions.length > 0) {
|
|
209
|
+
msg.regions.forEach((update) => {
|
|
210
|
+
this.updater.updateRegion(update.region, update.html)
|
|
211
|
+
})
|
|
212
|
+
} else if (msg.html) {
|
|
213
|
+
this.updater.update(msg.html)
|
|
214
|
+
}
|
|
215
|
+
break
|
|
216
|
+
|
|
217
|
+
case 'reload':
|
|
218
|
+
console.log('PyWire: Reloading...')
|
|
219
|
+
window.location.reload()
|
|
220
|
+
break
|
|
221
|
+
|
|
222
|
+
case 'error':
|
|
223
|
+
console.error('PyWire: Server error:', msg.error)
|
|
224
|
+
break
|
|
225
|
+
|
|
226
|
+
case 'error_trace':
|
|
227
|
+
// In core bundle, just log the error (no source loading)
|
|
228
|
+
console.error('PyWire: Error:', msg.error)
|
|
229
|
+
break
|
|
230
|
+
|
|
231
|
+
case 'console':
|
|
232
|
+
if (msg.lines && msg.lines.length > 0) {
|
|
233
|
+
const prefix = 'PyWire Server:'
|
|
234
|
+
const joined = msg.lines.join('\n')
|
|
235
|
+
if (msg.level === 'error') {
|
|
236
|
+
console.error(prefix, joined)
|
|
237
|
+
} else if (msg.level === 'warn') {
|
|
238
|
+
console.warn(prefix, joined)
|
|
239
|
+
} else {
|
|
240
|
+
console.log(prefix, joined)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
break
|
|
244
|
+
|
|
245
|
+
default:
|
|
246
|
+
console.warn('PyWire: Unknown message type', msg)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Get the current transport name.
|
|
252
|
+
*/
|
|
253
|
+
getTransport(): string | null {
|
|
254
|
+
return this.transport.getActiveTransport()
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Disconnect from the server.
|
|
259
|
+
*/
|
|
260
|
+
disconnect(): void {
|
|
261
|
+
this.transport.disconnect()
|
|
262
|
+
}
|
|
263
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { DOMUpdater } from './dom-updater'
|
|
3
|
+
import morphdom from 'morphdom'
|
|
4
|
+
|
|
5
|
+
vi.mock('morphdom', () => ({
|
|
6
|
+
default: vi.fn((_from, _to, _options) => {
|
|
7
|
+
// Basic simulation of morphdom: just replace content if no options,
|
|
8
|
+
// or call hooks if provided (we only care about onBeforeElUpdated)
|
|
9
|
+
return
|
|
10
|
+
}),
|
|
11
|
+
}))
|
|
12
|
+
|
|
13
|
+
describe('DOMUpdater', () => {
|
|
14
|
+
let updater: DOMUpdater
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
vi.clearAllMocks()
|
|
18
|
+
updater = new DOMUpdater()
|
|
19
|
+
document.documentElement.innerHTML = '<body><div id="app"></div></body>'
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('should call morphdom with custom options', () => {
|
|
23
|
+
updater.update('<html><body><div id="app">New</div></body></html>')
|
|
24
|
+
expect(morphdom).toHaveBeenCalledWith(
|
|
25
|
+
expect.any(Node),
|
|
26
|
+
expect.stringContaining('New'),
|
|
27
|
+
expect.objectContaining({
|
|
28
|
+
onBeforeElUpdated: expect.any(Function),
|
|
29
|
+
})
|
|
30
|
+
)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('should preserve input value if focused and similar', () => {
|
|
34
|
+
const morphdomMock = vi.mocked(morphdom)
|
|
35
|
+
updater.update('<html><body><input id="test" value="server"></body></html>')
|
|
36
|
+
|
|
37
|
+
// Get the onBeforeElUpdated hook
|
|
38
|
+
const options = morphdomMock.mock.calls[0][2]
|
|
39
|
+
const onBeforeElUpdated = options?.onBeforeElUpdated
|
|
40
|
+
|
|
41
|
+
if (!onBeforeElUpdated) throw new Error('Hook not found')
|
|
42
|
+
|
|
43
|
+
const fromEl = document.createElement('input')
|
|
44
|
+
fromEl.value = 'server-ahead'
|
|
45
|
+
vi.spyOn(document, 'activeElement', 'get').mockReturnValue(fromEl)
|
|
46
|
+
|
|
47
|
+
const toEl = document.createElement('input')
|
|
48
|
+
toEl.setAttribute('value', 'server')
|
|
49
|
+
|
|
50
|
+
const result = onBeforeElUpdated(fromEl, toEl)
|
|
51
|
+
|
|
52
|
+
expect(result).toBe(true)
|
|
53
|
+
expect(toEl.value).toBe('server-ahead')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('should NOT preserve input value if completely different', () => {
|
|
57
|
+
const morphdomMock = vi.mocked(morphdom)
|
|
58
|
+
updater.update('<html><body><input id="test" value="server"></body></html>')
|
|
59
|
+
|
|
60
|
+
const options = morphdomMock.mock.calls[0][2]
|
|
61
|
+
const onBeforeElUpdated = options?.onBeforeElUpdated
|
|
62
|
+
|
|
63
|
+
if (!onBeforeElUpdated) throw new Error('Hook not found')
|
|
64
|
+
|
|
65
|
+
const fromEl = document.createElement('input')
|
|
66
|
+
fromEl.value = 'user-typed-something-else'
|
|
67
|
+
vi.spyOn(document, 'activeElement', 'get').mockReturnValue(fromEl)
|
|
68
|
+
|
|
69
|
+
const toEl = document.createElement('input')
|
|
70
|
+
toEl.setAttribute('value', 'server-new')
|
|
71
|
+
toEl.value = 'server-new'
|
|
72
|
+
|
|
73
|
+
onBeforeElUpdated(fromEl, toEl)
|
|
74
|
+
|
|
75
|
+
// Should NOT have overwritten toEl.value with fromEl.value because they don't start with each other
|
|
76
|
+
expect(toEl.value).toBe('server-new')
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOM Updater using morphdom for efficient DOM diffing.
|
|
3
|
+
*/
|
|
4
|
+
import morphdom from 'morphdom'
|
|
5
|
+
|
|
6
|
+
interface FocusState {
|
|
7
|
+
/** CSS selector to find the element */
|
|
8
|
+
selector: string
|
|
9
|
+
/** Element ID if available */
|
|
10
|
+
id: string | null
|
|
11
|
+
tagName: string
|
|
12
|
+
selectionStart: number | null
|
|
13
|
+
selectionEnd: number | null
|
|
14
|
+
scrollTop: number
|
|
15
|
+
scrollLeft: number
|
|
16
|
+
value: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class DOMUpdater {
|
|
20
|
+
/**
|
|
21
|
+
* Flag to indicate DOM is being updated.
|
|
22
|
+
* Event handlers should check this to avoid triggering events during updates.
|
|
23
|
+
*/
|
|
24
|
+
static isUpdating = false
|
|
25
|
+
|
|
26
|
+
private debug: boolean
|
|
27
|
+
|
|
28
|
+
constructor(debug: boolean = false) {
|
|
29
|
+
this.debug = debug
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Generate a stable key for an element.
|
|
34
|
+
* Used by morphdom to match elements between old and new DOM.
|
|
35
|
+
*/
|
|
36
|
+
private getNodeKey(node: Node): string | undefined {
|
|
37
|
+
if (!(node instanceof HTMLElement)) return undefined
|
|
38
|
+
|
|
39
|
+
// 1. Use data-on-* handler as key FIRST (stable across renders)
|
|
40
|
+
for (const attr of node.attributes) {
|
|
41
|
+
if (attr.name.startsWith('data-on-')) {
|
|
42
|
+
const key = `${node.tagName}-${attr.name}-${attr.value}`
|
|
43
|
+
return key
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 2. Use explicit ID (but skip client-generated pywire-uid-* IDs)
|
|
48
|
+
if (node.id && !node.id.startsWith('pywire-uid-')) {
|
|
49
|
+
return node.id
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 3. Use name attribute for form elements
|
|
53
|
+
if (
|
|
54
|
+
node instanceof HTMLInputElement ||
|
|
55
|
+
node instanceof HTMLSelectElement ||
|
|
56
|
+
node instanceof HTMLTextAreaElement
|
|
57
|
+
) {
|
|
58
|
+
if (node.name) {
|
|
59
|
+
return `${node.tagName}-name-${node.name}`
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 4. For other elements, no key (morphdom will use position-based matching)
|
|
64
|
+
return undefined
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Generate a selector to find an element
|
|
69
|
+
*/
|
|
70
|
+
private getElementSelector(el: Element): string {
|
|
71
|
+
if (el.id) return `#${el.id}`
|
|
72
|
+
|
|
73
|
+
// Build a path-based selector
|
|
74
|
+
const path: string[] = []
|
|
75
|
+
let current: Element | null = el
|
|
76
|
+
|
|
77
|
+
while (current && current !== document.body && path.length < 5) {
|
|
78
|
+
let selector = current.tagName.toLowerCase()
|
|
79
|
+
|
|
80
|
+
// Add distinguishing attributes
|
|
81
|
+
if (current.id) {
|
|
82
|
+
selector = `#${current.id}`
|
|
83
|
+
path.unshift(selector)
|
|
84
|
+
break // ID is unique enough
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Use name for form elements
|
|
88
|
+
if (
|
|
89
|
+
current instanceof HTMLInputElement ||
|
|
90
|
+
current instanceof HTMLSelectElement ||
|
|
91
|
+
current instanceof HTMLTextAreaElement
|
|
92
|
+
) {
|
|
93
|
+
if (current.name) {
|
|
94
|
+
selector += `[name="${current.name}"]`
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Use data-on-* for event elements
|
|
99
|
+
for (const attr of current.attributes) {
|
|
100
|
+
if (attr.name.startsWith('data-on-')) {
|
|
101
|
+
selector += `[${attr.name}="${attr.value}"]`
|
|
102
|
+
break
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Add nth-child for disambiguation
|
|
107
|
+
if (current.parentElement) {
|
|
108
|
+
const sibs = Array.from(current.parentElement.children)
|
|
109
|
+
const sameTags = sibs.filter((s) => s.tagName === current!.tagName)
|
|
110
|
+
if (sameTags.length > 1) {
|
|
111
|
+
const idx = sameTags.indexOf(current) + 1
|
|
112
|
+
selector += `:nth-of-type(${idx})`
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
path.unshift(selector)
|
|
117
|
+
current = current.parentElement
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return path.join(' > ')
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Capture the current focus state before updating.
|
|
125
|
+
*/
|
|
126
|
+
private captureFocusState(): FocusState | null {
|
|
127
|
+
const active = document.activeElement
|
|
128
|
+
if (!active || active === document.body || active === document.documentElement) return null
|
|
129
|
+
|
|
130
|
+
const state: FocusState = {
|
|
131
|
+
selector: this.getElementSelector(active),
|
|
132
|
+
id: active.id || null,
|
|
133
|
+
tagName: active.tagName,
|
|
134
|
+
selectionStart: null,
|
|
135
|
+
selectionEnd: null,
|
|
136
|
+
scrollTop: 0,
|
|
137
|
+
scrollLeft: 0,
|
|
138
|
+
value: '',
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement) {
|
|
142
|
+
state.selectionStart = active.selectionStart
|
|
143
|
+
state.selectionEnd = active.selectionEnd
|
|
144
|
+
state.scrollTop = active.scrollTop
|
|
145
|
+
state.scrollLeft = active.scrollLeft
|
|
146
|
+
state.value = active.value
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return state
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Restore focus state after updating.
|
|
154
|
+
*/
|
|
155
|
+
private restoreFocusState(state: FocusState | null): void {
|
|
156
|
+
if (!state) return
|
|
157
|
+
|
|
158
|
+
// Try to find by ID first, then by selector
|
|
159
|
+
let el: Element | null = null
|
|
160
|
+
if (state.id) {
|
|
161
|
+
el = document.getElementById(state.id)
|
|
162
|
+
}
|
|
163
|
+
if (!el && state.selector) {
|
|
164
|
+
try {
|
|
165
|
+
el = document.querySelector(state.selector)
|
|
166
|
+
} catch {
|
|
167
|
+
// Invalid selector, skip
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (!el) return // Restore focus
|
|
172
|
+
;(el as HTMLElement).focus()
|
|
173
|
+
|
|
174
|
+
// Restore selection/caret position
|
|
175
|
+
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
|
|
176
|
+
// Restore value if it matches what we captured
|
|
177
|
+
if (state.value && el.value !== state.value) {
|
|
178
|
+
el.value = state.value
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (state.selectionStart !== null && state.selectionEnd !== null) {
|
|
182
|
+
try {
|
|
183
|
+
el.setSelectionRange(state.selectionStart, state.selectionEnd)
|
|
184
|
+
} catch {
|
|
185
|
+
// Some input types (date, number) don't support setSelectionRange
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
el.scrollTop = state.scrollTop
|
|
189
|
+
el.scrollLeft = state.scrollLeft
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private applyUpdate(target: Element, newHtml: string): void {
|
|
194
|
+
// Set flag to suppress focus/blur events during update
|
|
195
|
+
DOMUpdater.isUpdating = true
|
|
196
|
+
if (this.debug) {
|
|
197
|
+
console.log('[DOMUpdater] Starting update, isUpdating =', DOMUpdater.isUpdating)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
// Capture focus before morphdom runs
|
|
202
|
+
const focusState = this.captureFocusState()
|
|
203
|
+
|
|
204
|
+
if (morphdom) {
|
|
205
|
+
try {
|
|
206
|
+
morphdom(target, newHtml, {
|
|
207
|
+
// Custom key function for stable element matching
|
|
208
|
+
getNodeKey: (node: Node) => this.getNodeKey(node),
|
|
209
|
+
|
|
210
|
+
onBeforeElUpdated: (fromEl, toEl) => {
|
|
211
|
+
// Transfer ALL relevant state from old element to new element
|
|
212
|
+
|
|
213
|
+
// Input/Textarea: preserve value ONLY if they are broadly similar
|
|
214
|
+
// (e.g. user is still typing or deleted a few chars).
|
|
215
|
+
// If the server sends a completely different value, let it win.
|
|
216
|
+
if (fromEl instanceof HTMLInputElement && toEl instanceof HTMLInputElement) {
|
|
217
|
+
if (fromEl.type === 'checkbox' || fromEl.type === 'radio') {
|
|
218
|
+
toEl.checked = fromEl.checked
|
|
219
|
+
} else {
|
|
220
|
+
const s = toEl.value || ''
|
|
221
|
+
const c = fromEl.value || ''
|
|
222
|
+
if (c.startsWith(s) || s.startsWith(c)) {
|
|
223
|
+
toEl.value = c
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (fromEl instanceof HTMLTextAreaElement && toEl instanceof HTMLTextAreaElement) {
|
|
229
|
+
const s = toEl.value || ''
|
|
230
|
+
const c = fromEl.value || ''
|
|
231
|
+
if (c.startsWith(s) || s.startsWith(c)) {
|
|
232
|
+
toEl.value = c
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Select: preserve selected option
|
|
237
|
+
if (fromEl instanceof HTMLSelectElement && toEl instanceof HTMLSelectElement) {
|
|
238
|
+
// Preserve by value (more robust than index)
|
|
239
|
+
if (
|
|
240
|
+
fromEl.value &&
|
|
241
|
+
Array.from(toEl.options).some((o) => o.value === fromEl.value)
|
|
242
|
+
) {
|
|
243
|
+
toEl.value = fromEl.value
|
|
244
|
+
} else if (
|
|
245
|
+
fromEl.selectedIndex >= 0 &&
|
|
246
|
+
fromEl.selectedIndex < toEl.options.length
|
|
247
|
+
) {
|
|
248
|
+
toEl.selectedIndex = fromEl.selectedIndex
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Preserve client-generated IDs (vital for debouncers/throttlers that key off ID)
|
|
253
|
+
if (fromEl.id && fromEl.id.startsWith('pywire-uid-') && !toEl.id) {
|
|
254
|
+
toEl.id = fromEl.id
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return true
|
|
258
|
+
},
|
|
259
|
+
|
|
260
|
+
onBeforeNodeDiscarded: () => true,
|
|
261
|
+
})
|
|
262
|
+
} catch (e) {
|
|
263
|
+
console.error('Morphdom failed:', e)
|
|
264
|
+
if (target === document.documentElement) {
|
|
265
|
+
document.open()
|
|
266
|
+
document.write(newHtml)
|
|
267
|
+
document.close()
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Restore focus after morphdom completes
|
|
272
|
+
this.restoreFocusState(focusState)
|
|
273
|
+
} else if (target === document.documentElement) {
|
|
274
|
+
document.open()
|
|
275
|
+
document.write(newHtml)
|
|
276
|
+
document.close()
|
|
277
|
+
}
|
|
278
|
+
} finally {
|
|
279
|
+
// Clear flag after a microtask to ensure all focus events are suppressed
|
|
280
|
+
setTimeout(() => {
|
|
281
|
+
DOMUpdater.isUpdating = false
|
|
282
|
+
}, 0)
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Update the DOM with new HTML content.
|
|
288
|
+
*/
|
|
289
|
+
update(newHtml: string): void {
|
|
290
|
+
const hasHtmlRoot = /<html[\s>]/i.test(newHtml)
|
|
291
|
+
const hasBodyRoot = /<body[\s>]/i.test(newHtml)
|
|
292
|
+
if (!hasHtmlRoot && document.body) {
|
|
293
|
+
if (hasBodyRoot) {
|
|
294
|
+
this.applyUpdate(document.body, newHtml)
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
const wrapped = `<body>${newHtml}</body>`
|
|
298
|
+
this.applyUpdate(document.body, wrapped)
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
this.applyUpdate(document.documentElement, newHtml)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Update a specific region by its region id.
|
|
306
|
+
*/
|
|
307
|
+
updateRegion(regionId: string, regionHtml: string): void {
|
|
308
|
+
const target = document.querySelector(`[data-pw-region="${regionId}"]`)
|
|
309
|
+
if (!target) {
|
|
310
|
+
if (this.debug) {
|
|
311
|
+
console.warn('[DOMUpdater] Region not found:', regionId)
|
|
312
|
+
}
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
this.applyUpdate(target, regionHtml)
|
|
316
|
+
// Ensure the region anchor remains even if the server HTML omitted it.
|
|
317
|
+
if (!target.getAttribute('data-pw-region')) {
|
|
318
|
+
target.setAttribute('data-pw-region', regionId)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|