murasaki 0.0.3 → 0.1.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/bin/murasaki.js +2 -3
- package/package.json +20 -6
- package/src/cli/colors.ts +16 -0
- package/src/cli/log.ts +42 -0
- package/src/dev.tsx +51 -258
- package/src/env.ts +37 -0
- package/src/jsx/index.ts +20 -0
- package/src/jsx/jsx-dev-runtime.ts +6 -0
- package/src/jsx/jsx-runtime.ts +10 -0
- package/src/jsx/runtime.ts +278 -0
- package/src/jsx/types.ts +36 -0
- package/src/runtime/hmr.ts +26 -0
- package/src/runtime/load.ts +39 -0
- package/src/runtime/render.tsx +64 -0
- package/src/runtime/shortcuts.ts +31 -0
- package/src/runtime/window.ts +94 -0
- package/src/types.ts +22 -0
package/bin/murasaki.js
CHANGED
|
@@ -2,10 +2,9 @@
|
|
|
2
2
|
// Murasaki CLI entry point.
|
|
3
3
|
// Usage: murasaki dev
|
|
4
4
|
|
|
5
|
-
import 'tsx/esm'
|
|
6
|
-
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import 'tsx/esm' // Register the TS+JSX ESM loader
|
|
7
6
|
import { dirname, resolve } from 'node:path'
|
|
8
|
-
import { pathToFileURL } from 'node:url'
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
9
8
|
|
|
10
9
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
11
10
|
const cmd = process.argv[2]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "murasaki",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "The desktop framework for Next.js developers. Node-powered. WebView-thin. No Rust. No Chromium.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"desktop",
|
|
@@ -30,6 +30,18 @@
|
|
|
30
30
|
".": {
|
|
31
31
|
"types": "./src/index.ts",
|
|
32
32
|
"default": "./src/index.ts"
|
|
33
|
+
},
|
|
34
|
+
"./jsx": {
|
|
35
|
+
"types": "./src/jsx/index.ts",
|
|
36
|
+
"default": "./src/jsx/index.ts"
|
|
37
|
+
},
|
|
38
|
+
"./jsx-runtime": {
|
|
39
|
+
"types": "./src/jsx/jsx-runtime.ts",
|
|
40
|
+
"default": "./src/jsx/jsx-runtime.ts"
|
|
41
|
+
},
|
|
42
|
+
"./jsx-dev-runtime": {
|
|
43
|
+
"types": "./src/jsx/jsx-dev-runtime.ts",
|
|
44
|
+
"default": "./src/jsx/jsx-dev-runtime.ts"
|
|
33
45
|
}
|
|
34
46
|
},
|
|
35
47
|
"files": [
|
|
@@ -39,17 +51,19 @@
|
|
|
39
51
|
"LICENSE"
|
|
40
52
|
],
|
|
41
53
|
"scripts": {
|
|
42
|
-
"example:app-router": "cd examples/app-router && pnpm dev"
|
|
54
|
+
"example:app-router": "cd examples/app-router && pnpm dev",
|
|
55
|
+
"check": "biome check",
|
|
56
|
+
"lint": "biome lint",
|
|
57
|
+
"format": "biome format --write",
|
|
58
|
+
"fix": "biome check --write"
|
|
43
59
|
},
|
|
44
60
|
"dependencies": {
|
|
45
61
|
"@webviewjs/webview": "^0.3.2",
|
|
46
|
-
"react": "^19.2.7",
|
|
47
|
-
"react-dom": "^19.2.7",
|
|
48
62
|
"tsx": "^4.22.4"
|
|
49
63
|
},
|
|
50
64
|
"devDependencies": {
|
|
51
|
-
"@
|
|
52
|
-
"@types/
|
|
65
|
+
"@biomejs/biome": "^2.5.1",
|
|
66
|
+
"@types/node": "^26.0.1",
|
|
53
67
|
"typescript": "^6.0.3"
|
|
54
68
|
}
|
|
55
69
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// ANSI truecolor palette (Oomurasaki) + helper.
|
|
2
|
+
|
|
3
|
+
export const BRIGHT = '\x1b[38;2;168;85;247m'
|
|
4
|
+
export const DEEP = '\x1b[38;2;91;33;182m'
|
|
5
|
+
export const CREAM = '\x1b[38;2;250;245;232m'
|
|
6
|
+
export const DARK = '\x1b[38;2;59;7;100m'
|
|
7
|
+
export const DIM = '\x1b[38;2;136;136;153m'
|
|
8
|
+
export const GREEN = '\x1b[38;2;76;175;80m'
|
|
9
|
+
export const RED = '\x1b[38;2;239;68;68m'
|
|
10
|
+
export const BOLD = '\x1b[1m'
|
|
11
|
+
export const RESET = '\x1b[0m'
|
|
12
|
+
|
|
13
|
+
export const noColor = Boolean(process.env.NO_COLOR) || !process.stdout.isTTY
|
|
14
|
+
|
|
15
|
+
/** Wrap an ANSI escape; returns '' if colors are disabled. */
|
|
16
|
+
export const c = (code: string): string => (noColor ? '' : code)
|
package/src/cli/log.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Banner + status output for the dev runner.
|
|
2
|
+
|
|
3
|
+
import { projectRoot, VERSION, WEBVIEW_ENGINE } from '../env.ts'
|
|
4
|
+
import { BOLD, BRIGHT, c, DIM, GREEN, RED, RESET } from './colors.ts'
|
|
5
|
+
|
|
6
|
+
const out = (s: string) => process.stdout.write(s)
|
|
7
|
+
|
|
8
|
+
export function printBanner(winTitle: string, winSize: { width: number; height: number }) {
|
|
9
|
+
out('\n')
|
|
10
|
+
out(` ${c(BOLD)}${c(BRIGHT)}🦋 Murasaki${c(RESET)} ${c(DIM)}${VERSION}${c(RESET)}\n\n`)
|
|
11
|
+
out(` ${c(DIM)}-${c(RESET)} ${c(DIM)}Project ${c(RESET)}${projectRoot}\n`)
|
|
12
|
+
out(
|
|
13
|
+
` ${c(DIM)}-${c(RESET)} ${c(DIM)}Window ${c(RESET)}${winTitle} ${c(DIM)}(${winSize.width}×${winSize.height})${c(RESET)}\n`,
|
|
14
|
+
)
|
|
15
|
+
out(` ${c(DIM)}-${c(RESET)} ${c(DIM)}Webview ${c(RESET)}${WEBVIEW_ENGINE}\n`)
|
|
16
|
+
out(` ${c(DIM)}-${c(RESET)} ${c(DIM)}Runtime ${c(RESET)}Node ${process.version}\n`)
|
|
17
|
+
out(
|
|
18
|
+
` ${c(DIM)}-${c(RESET)} ${c(DIM)}Mode ${c(RESET)}development ${c(DIM)}(HMR active)${c(RESET)}\n\n`,
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function printShortcuts() {
|
|
23
|
+
out(
|
|
24
|
+
` ${c(DIM)}Shortcuts ${c(RESET)}${c(BOLD)}o${c(RESET)} ${c(DIM)}open${c(RESET)} ${c(BOLD)}r${c(RESET)} ${c(DIM)}restart${c(RESET)} ${c(BOLD)}q${c(RESET)} ${c(DIM)}quit${c(RESET)}\n\n`,
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const printStarting = () => out(` ${c(DIM)}○${c(RESET)} Starting...\n`)
|
|
29
|
+
export const printReady = (ms: number) =>
|
|
30
|
+
out(
|
|
31
|
+
` ${c(GREEN)}${c(BOLD)}✓${c(RESET)} ${c(BOLD)}Ready${c(RESET)} ${c(DIM)}in ${ms}ms${c(RESET)}\n`,
|
|
32
|
+
)
|
|
33
|
+
export const printOpened = () => out(` ${c(GREEN)}${c(BOLD)}✓${c(RESET)} Window opened\n`)
|
|
34
|
+
export const printClosed = () =>
|
|
35
|
+
out(
|
|
36
|
+
` ${c(DIM)}○${c(RESET)} Window closed ${c(DIM)}— press ${c(RESET)}${c(BOLD)}o${c(RESET)}${c(DIM)} to re-open, ${c(RESET)}${c(BOLD)}q${c(RESET)}${c(DIM)} to quit${c(RESET)}\n`,
|
|
37
|
+
)
|
|
38
|
+
export const printReloaded = (file: string) =>
|
|
39
|
+
out(` ${c(BRIGHT)}${c(BOLD)}↻${c(RESET)} Reloaded ${c(DIM)}${file}${c(RESET)}\n`)
|
|
40
|
+
export const printBye = () => out(`\n ${c(DIM)}Bye 🦋${c(RESET)}\n\n`)
|
|
41
|
+
export const printHint = (msg: string) => out(` ${c(DIM)}· ${msg}${c(RESET)}\n`)
|
|
42
|
+
export const printError = (msg: string) => out(` ${c(RED)}${c(BOLD)}✗${c(RESET)} ${msg}\n`)
|
package/src/dev.tsx
CHANGED
|
@@ -1,267 +1,60 @@
|
|
|
1
1
|
// src/dev.tsx
|
|
2
2
|
// Murasaki dev runner — Next.js-like file-based routing without Next.js.
|
|
3
3
|
//
|
|
4
|
-
// Reads the consumer's
|
|
5
|
-
//
|
|
6
|
-
// app
|
|
4
|
+
// Reads the consumer's src/ directory:
|
|
5
|
+
// src/layout.tsx (optional, can export `metadata`)
|
|
6
|
+
// src/app.tsx (required)
|
|
7
|
+
// src/globals.css (optional, auto-injected)
|
|
7
8
|
//
|
|
8
|
-
// Renders <Layout><
|
|
9
|
+
// Renders <Layout><App /></Layout> with React, ships HTML to the WebView,
|
|
9
10
|
// and reloads in place on file change.
|
|
10
11
|
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
// ── Resolve murasaki version (for the banner) ─────────────────────────
|
|
36
|
-
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
37
|
-
let VERSION = '0.0.0'
|
|
38
|
-
try {
|
|
39
|
-
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'))
|
|
40
|
-
VERSION = pkg.version || '0.0.0'
|
|
41
|
-
} catch {}
|
|
42
|
-
|
|
43
|
-
const WEBVIEW_ENGINE = (() => {
|
|
44
|
-
switch (process.platform) {
|
|
45
|
-
case 'darwin': return 'WKWebView (macOS)'
|
|
46
|
-
case 'win32': return 'WebView2 (Windows)'
|
|
47
|
-
case 'linux': return 'WebKitGTK (Linux)'
|
|
48
|
-
default: return `OS native (${process.platform})`
|
|
49
|
-
}
|
|
50
|
-
})()
|
|
51
|
-
|
|
52
|
-
const DEFAULT_WIN_TITLE = 'Murasaki App'
|
|
53
|
-
const DEFAULT_WIN_SIZE = { width: 1280, height: 800 }
|
|
54
|
-
const START_AT = Date.now()
|
|
55
|
-
const isDev = process.env.MURASAKI_DEV === '1' || true // dev runner always = dev
|
|
56
|
-
|
|
57
|
-
// Active window config (mutated when metadata is read)
|
|
58
|
-
let winTitle = DEFAULT_WIN_TITLE
|
|
59
|
-
let winSize = { ...DEFAULT_WIN_SIZE }
|
|
60
|
-
|
|
61
|
-
// ── Banner ────────────────────────────────────────────────────────────
|
|
62
|
-
function printBanner() {
|
|
63
|
-
process.stdout.write('\n')
|
|
64
|
-
process.stdout.write(` ${c(BOLD)}${c(BRIGHT)}🦋 Murasaki${c(RESET)} ${c(DIM)}${VERSION}${c(RESET)}\n\n`)
|
|
65
|
-
process.stdout.write(` ${c(DIM)}-${c(RESET)} ${c(DIM)}Project ${c(RESET)}${projectRoot}\n`)
|
|
66
|
-
process.stdout.write(` ${c(DIM)}-${c(RESET)} ${c(DIM)}Window ${c(RESET)}${winTitle} ${c(DIM)}(${winSize.width}×${winSize.height})${c(RESET)}\n`)
|
|
67
|
-
process.stdout.write(` ${c(DIM)}-${c(RESET)} ${c(DIM)}Webview ${c(RESET)}${WEBVIEW_ENGINE}\n`)
|
|
68
|
-
process.stdout.write(` ${c(DIM)}-${c(RESET)} ${c(DIM)}Runtime ${c(RESET)}Node ${process.version}\n`)
|
|
69
|
-
process.stdout.write(` ${c(DIM)}-${c(RESET)} ${c(DIM)}Mode ${c(RESET)}development ${c(DIM)}(HMR active)${c(RESET)}\n\n`)
|
|
70
|
-
}
|
|
71
|
-
function printShortcuts() { process.stdout.write(` ${c(DIM)}Shortcuts ${c(RESET)}${c(BOLD)}o${c(RESET)} ${c(DIM)}open${c(RESET)} ${c(BOLD)}r${c(RESET)} ${c(DIM)}restart${c(RESET)} ${c(BOLD)}q${c(RESET)} ${c(DIM)}quit${c(RESET)}\n\n`) }
|
|
72
|
-
function printStarting() { process.stdout.write(` ${c(DIM)}○${c(RESET)} Starting...\n`) }
|
|
73
|
-
function printReady(ms: number) { process.stdout.write(` ${c(GREEN)}${c(BOLD)}✓${c(RESET)} ${c(BOLD)}Ready${c(RESET)} ${c(DIM)}in ${ms}ms${c(RESET)}\n`) }
|
|
74
|
-
function printOpened() { process.stdout.write(` ${c(GREEN)}${c(BOLD)}✓${c(RESET)} Window opened\n`) }
|
|
75
|
-
function printClosed() { process.stdout.write(` ${c(DIM)}○${c(RESET)} Window closed ${c(DIM)}— press ${c(RESET)}${c(BOLD)}o${c(RESET)}${c(DIM)} to re-open, ${c(RESET)}${c(BOLD)}q${c(RESET)}${c(DIM)} to quit${c(RESET)}\n`) }
|
|
76
|
-
function printReloaded(file: string) { process.stdout.write(` ${c(BRIGHT)}${c(BOLD)}↻${c(RESET)} Reloaded ${c(DIM)}${file}${c(RESET)}\n`) }
|
|
77
|
-
function printBye() { process.stdout.write(`\n ${c(DIM)}Bye 🦋${c(RESET)}\n\n`) }
|
|
78
|
-
function printHint(msg: string) { process.stdout.write(` ${c(DIM)}· ${msg}${c(RESET)}\n`) }
|
|
79
|
-
function printError(msg: string) { process.stdout.write(` ${c(RED)}${c(BOLD)}✗${c(RESET)} ${msg}\n`) }
|
|
80
|
-
|
|
81
|
-
// ── Routing: load app/page.tsx (+ optional app/layout.tsx) ────────────
|
|
82
|
-
type ReactComponent = ComponentType<{ children?: ReactNode }>
|
|
83
|
-
|
|
84
|
-
type AppMetadata = {
|
|
85
|
-
title?: string
|
|
86
|
-
description?: string
|
|
87
|
-
window?: { title?: string; width?: number; height?: number }
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
type LayoutModule = { component: ReactComponent; metadata?: AppMetadata } | null
|
|
91
|
-
|
|
92
|
-
async function dynImport(path: string) {
|
|
93
|
-
// Cache-bust so file edits are picked up without restarting the process.
|
|
94
|
-
const url = pathToFileURL(path).href + `?v=${Date.now()}`
|
|
95
|
-
return import(url)
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
async function loadApp(): Promise<ReactComponent | null> {
|
|
99
|
-
if (!existsSync(APP_PATH)) return null
|
|
100
|
-
const mod = await dynImport(APP_PATH)
|
|
101
|
-
return mod.default as ReactComponent
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
async function loadLayout(): Promise<LayoutModule> {
|
|
105
|
-
if (!existsSync(LAYOUT_PATH)) return null
|
|
106
|
-
const mod = await dynImport(LAYOUT_PATH)
|
|
107
|
-
if (!mod.default) return null
|
|
108
|
-
return { component: mod.default as ReactComponent, metadata: mod.metadata as AppMetadata | undefined }
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function loadGlobalsCss(): string {
|
|
112
|
-
if (!existsSync(GLOBALS_CSS)) return ''
|
|
113
|
-
try { return readFileSync(GLOBALS_CSS, 'utf8') } catch { return '' }
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function escapeHtml(s: string) {
|
|
117
|
-
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
async function renderApp(): Promise<{ html: string; metadata?: AppMetadata }> {
|
|
121
|
-
const App = await loadApp()
|
|
122
|
-
if (!App) {
|
|
123
|
-
return { html: '<!doctype html><html><body style="font-family:system-ui;padding:40px;"><h1 style="color:#A855F7">src/app.tsx not found</h1><p>Create one and the window will reload.</p></body></html>' }
|
|
124
|
-
}
|
|
125
|
-
const layoutData = await loadLayout()
|
|
126
|
-
const metadata = layoutData?.metadata
|
|
127
|
-
const appEl = createElement(App)
|
|
128
|
-
const tree = layoutData ? createElement(layoutData.component, null, appEl) : appEl
|
|
129
|
-
let html = '<!doctype html>' + renderToStaticMarkup(tree)
|
|
130
|
-
|
|
131
|
-
// Inject <title> + <meta description> from metadata if not present in head
|
|
132
|
-
const headInjects: string[] = []
|
|
133
|
-
if (metadata?.title && !/<title>.*?<\/title>/i.test(html)) {
|
|
134
|
-
headInjects.push(`<title>${escapeHtml(metadata.title)}</title>`)
|
|
135
|
-
}
|
|
136
|
-
if (metadata?.description && !/<meta[^>]+name=["']description["']/i.test(html)) {
|
|
137
|
-
headInjects.push(`<meta name="description" content="${escapeHtml(metadata.description)}">`)
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// Inject src/globals.css (auto, like Next.js's import './globals.css')
|
|
141
|
-
const css = loadGlobalsCss()
|
|
142
|
-
if (css) {
|
|
143
|
-
headInjects.push(`<style data-murasaki="globals.css">${css}</style>`)
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
if (headInjects.length) {
|
|
147
|
-
const blob = headInjects.join('')
|
|
148
|
-
if (html.includes('</head>')) {
|
|
149
|
-
html = html.replace('</head>', blob + '</head>')
|
|
150
|
-
} else {
|
|
151
|
-
html = html.replace('<body', blob + '<body')
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
return { html, metadata }
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// ── Window lifecycle ──────────────────────────────────────────────────
|
|
159
|
-
const app = new Application({ controlFlow: ControlFlow.Wait })
|
|
160
|
-
let win: ReturnType<typeof app.createBrowserWindow> | null = null
|
|
161
|
-
let webview: ReturnType<NonNullable<typeof win>['createWebview']> | null = null
|
|
162
|
-
|
|
163
|
-
app.onEvent((event) => {
|
|
164
|
-
const kind = event && ((event as any).kind || (event as any).event)
|
|
165
|
-
if (kind === 'window-close-requested') {
|
|
166
|
-
// Dispose the window so the OS close completes — without this the
|
|
167
|
-
// close button hangs / appears frozen.
|
|
168
|
-
if (win) {
|
|
169
|
-
try { win.dispose() } catch {}
|
|
170
|
-
}
|
|
171
|
-
win = null
|
|
172
|
-
webview = null
|
|
173
|
-
printClosed()
|
|
174
|
-
}
|
|
175
|
-
})
|
|
176
|
-
|
|
177
|
-
function applyMetadataToWindowConfig(metadata?: AppMetadata) {
|
|
178
|
-
if (!metadata) return
|
|
179
|
-
if (metadata.window?.title) winTitle = metadata.window.title
|
|
180
|
-
else if (metadata.title) winTitle = metadata.title
|
|
181
|
-
if (metadata.window?.width) winSize.width = metadata.window.width
|
|
182
|
-
if (metadata.window?.height) winSize.height = metadata.window.height
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
async function openWindow() {
|
|
186
|
-
if (win) { printHint('Window is already open'); return }
|
|
187
|
-
// Render first so we can read metadata and apply window config
|
|
188
|
-
const { html, metadata } = await renderApp()
|
|
189
|
-
applyMetadataToWindowConfig(metadata)
|
|
190
|
-
win = app.createBrowserWindow({
|
|
191
|
-
title: winTitle,
|
|
192
|
-
width: winSize.width,
|
|
193
|
-
height: winSize.height,
|
|
194
|
-
})
|
|
195
|
-
webview = win.createWebview({ html })
|
|
196
|
-
printOpened()
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
function closeWindow() {
|
|
200
|
-
if (!win) return
|
|
201
|
-
try { win.dispose() } catch {}
|
|
202
|
-
win = null
|
|
203
|
-
webview = null
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
async function reload(triggerFile: string) {
|
|
207
|
-
if (!webview) return
|
|
208
|
-
try {
|
|
209
|
-
const { html } = await renderApp()
|
|
210
|
-
webview.loadHtml(html)
|
|
211
|
-
printReloaded(triggerFile.replace(projectRoot + '/', ''))
|
|
212
|
-
} catch (e: any) {
|
|
213
|
-
printError(`Reload failed: ${e.message}`)
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// ── File watcher (HMR for src/) ───────────────────────────────────────
|
|
218
|
-
function setupHmr() {
|
|
219
|
-
if (!existsSync(SRC_DIR)) {
|
|
220
|
-
printHint('src/ directory not found — nothing to watch')
|
|
221
|
-
return
|
|
222
|
-
}
|
|
223
|
-
let debounce: NodeJS.Timeout | null = null
|
|
224
|
-
let lastFile = ''
|
|
225
|
-
try {
|
|
226
|
-
watch(SRC_DIR, { recursive: true }, (_event, filename) => {
|
|
227
|
-
if (!filename) return
|
|
228
|
-
if (debounce) clearTimeout(debounce)
|
|
229
|
-
lastFile = filename
|
|
230
|
-
debounce = setTimeout(() => { reload(lastFile) }, 80)
|
|
231
|
-
})
|
|
232
|
-
} catch (e: any) {
|
|
233
|
-
printHint(`HMR watcher failed: ${e.message}`)
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// ── Keyboard shortcuts ────────────────────────────────────────────────
|
|
238
|
-
function setupShortcuts() {
|
|
239
|
-
if (!process.stdin.isTTY) return
|
|
240
|
-
process.stdin.setRawMode(true)
|
|
241
|
-
process.stdin.resume()
|
|
242
|
-
process.stdin.setEncoding('utf8')
|
|
243
|
-
process.stdin.on('data', (key: string) => {
|
|
244
|
-
if (key === 'o' || key === 'O') {
|
|
245
|
-
openWindow()
|
|
246
|
-
} else if (key === 'r' || key === 'R') {
|
|
247
|
-
closeWindow()
|
|
248
|
-
openWindow()
|
|
249
|
-
} else if (key === 'q' || key === 'Q' || key === '' /* Ctrl+C */) {
|
|
250
|
-
printBye()
|
|
251
|
-
try { process.stdin.setRawMode(false) } catch {}
|
|
252
|
-
try { app.exit() } catch {}
|
|
253
|
-
process.exit(0)
|
|
254
|
-
}
|
|
255
|
-
})
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// ── Boot ──────────────────────────────────────────────────────────────
|
|
259
|
-
printBanner()
|
|
12
|
+
import {
|
|
13
|
+
printBanner,
|
|
14
|
+
printBye,
|
|
15
|
+
printOpened,
|
|
16
|
+
printReady,
|
|
17
|
+
printShortcuts,
|
|
18
|
+
printStarting,
|
|
19
|
+
} from './cli/log.ts'
|
|
20
|
+
import { setupHmr } from './runtime/hmr.ts'
|
|
21
|
+
import { setupShortcuts, teardownStdin } from './runtime/shortcuts.ts'
|
|
22
|
+
import {
|
|
23
|
+
closeWindow,
|
|
24
|
+
exitApp,
|
|
25
|
+
getConfig,
|
|
26
|
+
openWindow,
|
|
27
|
+
reloadWindow,
|
|
28
|
+
runApp,
|
|
29
|
+
} from './runtime/window.ts'
|
|
30
|
+
|
|
31
|
+
const startAt = Date.now()
|
|
32
|
+
|
|
33
|
+
// Boot: render once (applies metadata) → banner → ready
|
|
34
|
+
await openWindow()
|
|
35
|
+
printBanner(getConfig().title, getConfig())
|
|
260
36
|
printShortcuts()
|
|
261
37
|
printStarting()
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
setupShortcuts(
|
|
265
|
-
|
|
38
|
+
printReady(Date.now() - startAt)
|
|
39
|
+
|
|
40
|
+
setupShortcuts({
|
|
41
|
+
onOpen: () => {
|
|
42
|
+
void openWindow().then(printOpened)
|
|
43
|
+
},
|
|
44
|
+
onRestart: () => {
|
|
45
|
+
closeWindow()
|
|
46
|
+
void openWindow().then(printOpened)
|
|
47
|
+
},
|
|
48
|
+
onQuit: () => {
|
|
49
|
+
printBye()
|
|
50
|
+
teardownStdin()
|
|
51
|
+
exitApp()
|
|
52
|
+
process.exit(0)
|
|
53
|
+
},
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
setupHmr((file) => {
|
|
57
|
+
void reloadWindow(file)
|
|
58
|
+
})
|
|
266
59
|
|
|
267
|
-
|
|
60
|
+
runApp()
|
package/src/env.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Project paths + version + platform — resolved once at boot.
|
|
2
|
+
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
import { dirname, join } from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
|
|
7
|
+
export const projectRoot = process.cwd()
|
|
8
|
+
export const SRC_DIR = join(projectRoot, 'src')
|
|
9
|
+
export const APP_PATH = join(SRC_DIR, 'app.tsx')
|
|
10
|
+
export const LAYOUT_PATH = join(SRC_DIR, 'layout.tsx')
|
|
11
|
+
export const GLOBALS_CSS = join(SRC_DIR, 'globals.css')
|
|
12
|
+
|
|
13
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
14
|
+
export const VERSION: string = (() => {
|
|
15
|
+
try {
|
|
16
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'))
|
|
17
|
+
return pkg.version || '0.0.0'
|
|
18
|
+
} catch {
|
|
19
|
+
return '0.0.0'
|
|
20
|
+
}
|
|
21
|
+
})()
|
|
22
|
+
|
|
23
|
+
export const WEBVIEW_ENGINE: string = (() => {
|
|
24
|
+
switch (process.platform) {
|
|
25
|
+
case 'darwin':
|
|
26
|
+
return 'WKWebView (macOS)'
|
|
27
|
+
case 'win32':
|
|
28
|
+
return 'WebView2 (Windows)'
|
|
29
|
+
case 'linux':
|
|
30
|
+
return 'WebKitGTK (Linux)'
|
|
31
|
+
default:
|
|
32
|
+
return `OS native (${process.platform})`
|
|
33
|
+
}
|
|
34
|
+
})()
|
|
35
|
+
|
|
36
|
+
export const DEFAULT_WIN_TITLE = 'Murasaki App'
|
|
37
|
+
export const DEFAULT_WIN_SIZE = { width: 1280, height: 800 }
|
package/src/jsx/index.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Public surface for `import { ... } from 'murasaki/jsx'`.
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
createElement,
|
|
5
|
+
Fragment,
|
|
6
|
+
isJSXNode,
|
|
7
|
+
isValidElement,
|
|
8
|
+
JSXNode,
|
|
9
|
+
jsx,
|
|
10
|
+
renderToString,
|
|
11
|
+
} from './runtime.ts'
|
|
12
|
+
|
|
13
|
+
export type {
|
|
14
|
+
Child,
|
|
15
|
+
Component,
|
|
16
|
+
Element,
|
|
17
|
+
FC,
|
|
18
|
+
JSXNodeLike,
|
|
19
|
+
Props,
|
|
20
|
+
} from './types.ts'
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// JSX dev runtime entry — used when tsconfig has `jsx: "react-jsxdev"`.
|
|
2
|
+
// We don't (yet) track source locations or component stacks, so jsxDEV
|
|
3
|
+
// behaves identically to jsx.
|
|
4
|
+
|
|
5
|
+
export { Fragment, jsx as jsxDEV } from './runtime.ts'
|
|
6
|
+
export type { JSX } from './types.ts'
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// JSX automatic runtime entry — consumed by `tsx` / `esbuild` when the
|
|
2
|
+
// user's tsconfig has `jsxImportSource: "murasaki"`.
|
|
3
|
+
//
|
|
4
|
+
// The transform emits calls like:
|
|
5
|
+
// jsx(Component, props, key?)
|
|
6
|
+
// jsxs(Component, propsWithChildrenArray, key?)
|
|
7
|
+
// <></> → jsx(Fragment, { children: [...] })
|
|
8
|
+
|
|
9
|
+
export { Fragment, jsx, jsx as jsxs } from './runtime.ts'
|
|
10
|
+
export type { JSX } from './types.ts'
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// murasaki/jsx — SSR-only JSX runtime.
|
|
2
|
+
//
|
|
3
|
+
// Inspired by hono/jsx but trimmed for desktop-server use:
|
|
4
|
+
// - no hooks (the view is rendered once per HMR cycle, no client state)
|
|
5
|
+
// - no DOM renderer (we ship HTML to the OS WebView and that's it)
|
|
6
|
+
// - no streaming / Suspense (single render → loadHtml())
|
|
7
|
+
// - React-compatible enough to swap in for renderToStaticMarkup
|
|
8
|
+
//
|
|
9
|
+
// Two-step pipeline:
|
|
10
|
+
// 1. jsx(tag, props) → JSXNode (tree)
|
|
11
|
+
// 2. JSXNode.toString() → HTML string
|
|
12
|
+
//
|
|
13
|
+
// User code that runs is the *user's* JSX (transformed to jsx() calls
|
|
14
|
+
// by tsx/esbuild with `jsxImportSource: "murasaki"`).
|
|
15
|
+
|
|
16
|
+
import type { Child, Component, JSXNodeLike, Props } from './types.ts'
|
|
17
|
+
|
|
18
|
+
// ── HTML escape ──────────────────────────────────────────────────────
|
|
19
|
+
const AMP = /&/g
|
|
20
|
+
const LT = /</g
|
|
21
|
+
const GT = />/g
|
|
22
|
+
const QT = /"/g
|
|
23
|
+
|
|
24
|
+
function escapeHtml(s: string): string {
|
|
25
|
+
return s.replace(AMP, '&').replace(LT, '<').replace(GT, '>')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function escapeAttr(s: string): string {
|
|
29
|
+
return s.replace(AMP, '&').replace(QT, '"')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── Void elements (no closing tag) ────────────────────────────────────
|
|
33
|
+
const VOID_ELEMENTS = new Set([
|
|
34
|
+
'area',
|
|
35
|
+
'base',
|
|
36
|
+
'br',
|
|
37
|
+
'col',
|
|
38
|
+
'embed',
|
|
39
|
+
'hr',
|
|
40
|
+
'img',
|
|
41
|
+
'input',
|
|
42
|
+
'link',
|
|
43
|
+
'meta',
|
|
44
|
+
'source',
|
|
45
|
+
'track',
|
|
46
|
+
'wbr',
|
|
47
|
+
])
|
|
48
|
+
|
|
49
|
+
// ── Attribute name normalization (React → HTML) ───────────────────────
|
|
50
|
+
const ATTR_ALIAS: Record<string, string> = {
|
|
51
|
+
className: 'class',
|
|
52
|
+
htmlFor: 'for',
|
|
53
|
+
charSet: 'charset',
|
|
54
|
+
crossOrigin: 'crossorigin',
|
|
55
|
+
httpEquiv: 'http-equiv',
|
|
56
|
+
itemProp: 'itemprop',
|
|
57
|
+
fetchPriority: 'fetchpriority',
|
|
58
|
+
noModule: 'nomodule',
|
|
59
|
+
formAction: 'formaction',
|
|
60
|
+
acceptCharset: 'accept-charset',
|
|
61
|
+
autoComplete: 'autocomplete',
|
|
62
|
+
autoFocus: 'autofocus',
|
|
63
|
+
autoPlay: 'autoplay',
|
|
64
|
+
contentEditable: 'contenteditable',
|
|
65
|
+
defaultValue: 'value',
|
|
66
|
+
defaultChecked: 'checked',
|
|
67
|
+
encType: 'enctype',
|
|
68
|
+
formMethod: 'formmethod',
|
|
69
|
+
formNoValidate: 'formnovalidate',
|
|
70
|
+
formTarget: 'formtarget',
|
|
71
|
+
maxLength: 'maxlength',
|
|
72
|
+
minLength: 'minlength',
|
|
73
|
+
noValidate: 'novalidate',
|
|
74
|
+
readOnly: 'readonly',
|
|
75
|
+
rowSpan: 'rowspan',
|
|
76
|
+
colSpan: 'colspan',
|
|
77
|
+
spellCheck: 'spellcheck',
|
|
78
|
+
tabIndex: 'tabindex',
|
|
79
|
+
useMap: 'usemap',
|
|
80
|
+
srcDoc: 'srcdoc',
|
|
81
|
+
srcSet: 'srcset',
|
|
82
|
+
hrefLang: 'hreflang',
|
|
83
|
+
dateTime: 'datetime',
|
|
84
|
+
enterKeyHint: 'enterkeyhint',
|
|
85
|
+
inputMode: 'inputmode',
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function normalizeAttrName(k: string): string {
|
|
89
|
+
return ATTR_ALIAS[k] || k
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Style object → CSS string ─────────────────────────────────────────
|
|
93
|
+
function camelToKebab(k: string): string {
|
|
94
|
+
// Leave already-kebab keys and CSS custom props (--foo) alone.
|
|
95
|
+
if (k[0] === '-' || !/[A-Z]/.test(k)) return k
|
|
96
|
+
return k.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// CSS properties that take unitless numbers (subset of React's list).
|
|
100
|
+
const UNITLESS = new Set([
|
|
101
|
+
'animationIterationCount',
|
|
102
|
+
'borderImageOutset',
|
|
103
|
+
'borderImageSlice',
|
|
104
|
+
'borderImageWidth',
|
|
105
|
+
'boxFlex',
|
|
106
|
+
'boxFlexGroup',
|
|
107
|
+
'boxOrdinalGroup',
|
|
108
|
+
'columnCount',
|
|
109
|
+
'columns',
|
|
110
|
+
'flex',
|
|
111
|
+
'flexGrow',
|
|
112
|
+
'flexShrink',
|
|
113
|
+
'fontWeight',
|
|
114
|
+
'gridArea',
|
|
115
|
+
'gridColumn',
|
|
116
|
+
'gridColumnEnd',
|
|
117
|
+
'gridColumnStart',
|
|
118
|
+
'gridRow',
|
|
119
|
+
'gridRowEnd',
|
|
120
|
+
'gridRowStart',
|
|
121
|
+
'lineClamp',
|
|
122
|
+
'lineHeight',
|
|
123
|
+
'opacity',
|
|
124
|
+
'order',
|
|
125
|
+
'orphans',
|
|
126
|
+
'tabSize',
|
|
127
|
+
'widows',
|
|
128
|
+
'zIndex',
|
|
129
|
+
'zoom',
|
|
130
|
+
])
|
|
131
|
+
|
|
132
|
+
function styleObjToString(obj: Record<string, unknown>): string {
|
|
133
|
+
let out = ''
|
|
134
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
135
|
+
if (v == null || v === false) continue
|
|
136
|
+
let value: string
|
|
137
|
+
if (typeof v === 'number') {
|
|
138
|
+
value = UNITLESS.has(k) ? String(v) : `${v}px`
|
|
139
|
+
} else if (typeof v === 'string') {
|
|
140
|
+
value = v
|
|
141
|
+
} else {
|
|
142
|
+
continue
|
|
143
|
+
}
|
|
144
|
+
out += `${out ? ';' : ''}${camelToKebab(k)}:${value}`
|
|
145
|
+
}
|
|
146
|
+
return out
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── Attribute rendering ──────────────────────────────────────────────
|
|
150
|
+
function renderAttrs(props: Props): string {
|
|
151
|
+
let out = ''
|
|
152
|
+
for (const k in props) {
|
|
153
|
+
if (k === 'children' || k === 'key' || k === 'ref' || k === '__source' || k === '__self')
|
|
154
|
+
continue
|
|
155
|
+
const v = props[k]
|
|
156
|
+
if (v == null || v === false) continue
|
|
157
|
+
|
|
158
|
+
const name = normalizeAttrName(k)
|
|
159
|
+
|
|
160
|
+
// Boolean attribute
|
|
161
|
+
if (v === true) {
|
|
162
|
+
out += ` ${name}`
|
|
163
|
+
continue
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Inline style object
|
|
167
|
+
if (k === 'style' && typeof v === 'object') {
|
|
168
|
+
const css = styleObjToString(v as Record<string, unknown>)
|
|
169
|
+
if (css) out += ` style="${escapeAttr(css)}"`
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// dangerouslySetInnerHTML is handled in JSXNode.toString(), skip here
|
|
174
|
+
if (k === 'dangerouslySetInnerHTML') continue
|
|
175
|
+
|
|
176
|
+
out += ` ${name}="${escapeAttr(String(v))}"`
|
|
177
|
+
}
|
|
178
|
+
return out
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── Children rendering ───────────────────────────────────────────────
|
|
182
|
+
function renderChild(c: Child): string {
|
|
183
|
+
if (c == null || c === false || c === true) return ''
|
|
184
|
+
if (typeof c === 'string') return escapeHtml(c)
|
|
185
|
+
if (typeof c === 'number' || typeof c === 'bigint') return String(c)
|
|
186
|
+
if (Array.isArray(c)) {
|
|
187
|
+
let s = ''
|
|
188
|
+
for (const item of c) s += renderChild(item)
|
|
189
|
+
return s
|
|
190
|
+
}
|
|
191
|
+
if (isJSXNode(c)) return c.toString()
|
|
192
|
+
return ''
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── JSXNode ──────────────────────────────────────────────────────────
|
|
196
|
+
export class JSXNode implements JSXNodeLike {
|
|
197
|
+
readonly __isJSXNode = true as const
|
|
198
|
+
tag: string | Component
|
|
199
|
+
props: Props
|
|
200
|
+
children: Child[]
|
|
201
|
+
|
|
202
|
+
constructor(tag: string | Component, props: Props, children: Child[]) {
|
|
203
|
+
this.tag = tag
|
|
204
|
+
this.props = props
|
|
205
|
+
this.children = children
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
toString(): string {
|
|
209
|
+
const { tag, props, children } = this
|
|
210
|
+
|
|
211
|
+
// Fragment / functional component
|
|
212
|
+
if (typeof tag === 'function') {
|
|
213
|
+
// Always pass children via props (React-compat)
|
|
214
|
+
const merged = { ...props, children: children.length === 1 ? children[0] : children }
|
|
215
|
+
const result = tag(merged)
|
|
216
|
+
return renderChild(result as Child)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Intrinsic element
|
|
220
|
+
const attrs = renderAttrs(props)
|
|
221
|
+
|
|
222
|
+
// dangerouslySetInnerHTML overrides children
|
|
223
|
+
const dsi = props.dangerouslySetInnerHTML as { __html?: string } | undefined
|
|
224
|
+
if (dsi && typeof dsi.__html === 'string') {
|
|
225
|
+
return `<${tag}${attrs}>${dsi.__html}</${tag}>`
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (VOID_ELEMENTS.has(tag)) {
|
|
229
|
+
// Self-closing for void elements
|
|
230
|
+
return `<${tag}${attrs}/>`
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
let childHtml = ''
|
|
234
|
+
for (const c of children) childHtml += renderChild(c)
|
|
235
|
+
|
|
236
|
+
return `<${tag}${attrs}>${childHtml}</${tag}>`
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function isJSXNode(v: unknown): v is JSXNode {
|
|
241
|
+
return typeof v === 'object' && v !== null && (v as JSXNodeLike).__isJSXNode === true
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── Public factory (React-compat: createElement / jsx) ────────────────
|
|
245
|
+
export function jsx(
|
|
246
|
+
tag: string | Component,
|
|
247
|
+
props: Props | null,
|
|
248
|
+
..._restChildren: unknown[]
|
|
249
|
+
): JSXNode {
|
|
250
|
+
const p = props ?? {}
|
|
251
|
+
const rawChildren = (p as { children?: Child }).children
|
|
252
|
+
const children: Child[] = Array.isArray(rawChildren)
|
|
253
|
+
? rawChildren
|
|
254
|
+
: rawChildren != null
|
|
255
|
+
? [rawChildren]
|
|
256
|
+
: []
|
|
257
|
+
// Strip children from props (it's stored separately)
|
|
258
|
+
const { children: _drop, ...rest } = p as Props & { children?: Child }
|
|
259
|
+
return new JSXNode(tag, rest, children)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** React-compatible alias. */
|
|
263
|
+
export const createElement = jsx
|
|
264
|
+
|
|
265
|
+
/** Fragment — renders children without a wrapper tag. */
|
|
266
|
+
export function Fragment(props: { children?: Child }): Child {
|
|
267
|
+
return props.children ?? null
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Check if something is a JSX element (React.isValidElement compat). */
|
|
271
|
+
export function isValidElement(v: unknown): v is JSXNode {
|
|
272
|
+
return isJSXNode(v)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Convert any value (JSXNode, string, array, etc.) to an HTML string. */
|
|
276
|
+
export function renderToString(value: Child): string {
|
|
277
|
+
return renderChild(value)
|
|
278
|
+
}
|
package/src/jsx/types.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Public JSX types for murasaki/jsx.
|
|
2
|
+
|
|
3
|
+
export type Props = Record<string, unknown>
|
|
4
|
+
|
|
5
|
+
export type Child = string | number | bigint | boolean | null | undefined | JSXNodeLike | Child[]
|
|
6
|
+
|
|
7
|
+
export interface JSXNodeLike {
|
|
8
|
+
readonly __isJSXNode: true
|
|
9
|
+
tag: string | Component
|
|
10
|
+
props: Props
|
|
11
|
+
children: Child[]
|
|
12
|
+
toString(): string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type Component<P = Props> = (props: P & { children?: Child }) => Child
|
|
16
|
+
|
|
17
|
+
/** React-compatible alias used by most user code. */
|
|
18
|
+
export type FC<P = Props> = Component<P>
|
|
19
|
+
|
|
20
|
+
/** For component refs / cloning. */
|
|
21
|
+
export type Element = JSXNodeLike
|
|
22
|
+
|
|
23
|
+
declare global {
|
|
24
|
+
namespace JSX {
|
|
25
|
+
type Element = JSXNodeLike
|
|
26
|
+
interface ElementChildrenAttribute {
|
|
27
|
+
children: object
|
|
28
|
+
}
|
|
29
|
+
// Loose intrinsic catalog — every HTML/SVG tag accepts any prop.
|
|
30
|
+
// (Tightening this to a real catalog is a follow-up; keeps the
|
|
31
|
+
// runtime usable without bloating the type surface today.)
|
|
32
|
+
interface IntrinsicElements {
|
|
33
|
+
[tagName: string]: Record<string, unknown> & { children?: Child }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// File watcher for src/ — debounces multi-event saves and triggers reload.
|
|
2
|
+
|
|
3
|
+
import { existsSync, watch } from 'node:fs'
|
|
4
|
+
import { printHint } from '../cli/log.ts'
|
|
5
|
+
import { SRC_DIR } from '../env.ts'
|
|
6
|
+
|
|
7
|
+
export function setupHmr(onChange: (filename: string) => void): void {
|
|
8
|
+
if (!existsSync(SRC_DIR)) {
|
|
9
|
+
printHint('src/ directory not found — nothing to watch')
|
|
10
|
+
return
|
|
11
|
+
}
|
|
12
|
+
let debounce: NodeJS.Timeout | null = null
|
|
13
|
+
let lastFile = ''
|
|
14
|
+
try {
|
|
15
|
+
watch(SRC_DIR, { recursive: true }, (_event, filename) => {
|
|
16
|
+
if (!filename) return
|
|
17
|
+
if (debounce) clearTimeout(debounce)
|
|
18
|
+
lastFile = filename.toString()
|
|
19
|
+
debounce = setTimeout(() => {
|
|
20
|
+
onChange(lastFile)
|
|
21
|
+
}, 80)
|
|
22
|
+
})
|
|
23
|
+
} catch (e: any) {
|
|
24
|
+
printHint(`HMR watcher failed: ${e.message}`)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Loads the user's src/app.tsx, src/layout.tsx, src/globals.css.
|
|
2
|
+
// Uses cache-busting dynamic import so file edits are picked up
|
|
3
|
+
// without restarting the Node process.
|
|
4
|
+
|
|
5
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
6
|
+
import { pathToFileURL } from 'node:url'
|
|
7
|
+
import { APP_PATH, GLOBALS_CSS, LAYOUT_PATH } from '../env.ts'
|
|
8
|
+
import type { Metadata } from '../index.ts'
|
|
9
|
+
import type { AppComponent, LayoutModule } from '../types.ts'
|
|
10
|
+
|
|
11
|
+
async function dynImport(path: string) {
|
|
12
|
+
const url = pathToFileURL(path).href + `?v=${Date.now()}`
|
|
13
|
+
return import(url)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function loadApp(): Promise<AppComponent | null> {
|
|
17
|
+
if (!existsSync(APP_PATH)) return null
|
|
18
|
+
const mod = await dynImport(APP_PATH)
|
|
19
|
+
return mod.default as AppComponent
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function loadLayout(): Promise<LayoutModule> {
|
|
23
|
+
if (!existsSync(LAYOUT_PATH)) return null
|
|
24
|
+
const mod = await dynImport(LAYOUT_PATH)
|
|
25
|
+
if (!mod.default) return null
|
|
26
|
+
return {
|
|
27
|
+
component: mod.default as AppComponent,
|
|
28
|
+
metadata: mod.metadata as Metadata | undefined,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function loadGlobalsCss(): string {
|
|
33
|
+
if (!existsSync(GLOBALS_CSS)) return ''
|
|
34
|
+
try {
|
|
35
|
+
return readFileSync(GLOBALS_CSS, 'utf8')
|
|
36
|
+
} catch {
|
|
37
|
+
return ''
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Renders <Layout><App /></Layout> to an HTML string and injects
|
|
2
|
+
// metadata (<title>, <meta description>) + src/globals.css.
|
|
3
|
+
//
|
|
4
|
+
// Uses murasaki/jsx (no React dependency).
|
|
5
|
+
// The user's src/app.tsx / src/layout.tsx is transformed by tsx with
|
|
6
|
+
// `jsxImportSource: "murasaki"` so their <div> calls into our jsx().
|
|
7
|
+
|
|
8
|
+
import { jsx, renderToString } from '../jsx/runtime.ts'
|
|
9
|
+
import type { Child, Component } from '../jsx/types.ts'
|
|
10
|
+
import type { RenderResult } from '../types.ts'
|
|
11
|
+
import { loadApp, loadGlobalsCss, loadLayout } from './load.ts'
|
|
12
|
+
|
|
13
|
+
const NO_APP_HTML =
|
|
14
|
+
'<!doctype html><html><body style="font-family:system-ui;padding:40px;">' +
|
|
15
|
+
'<h1 style="color:#A855F7">src/app.tsx not found</h1>' +
|
|
16
|
+
'<p>Create one and the window will reload.</p></body></html>'
|
|
17
|
+
|
|
18
|
+
function escapeHtml(s: string): string {
|
|
19
|
+
return s
|
|
20
|
+
.replace(/&/g, '&')
|
|
21
|
+
.replace(/</g, '<')
|
|
22
|
+
.replace(/>/g, '>')
|
|
23
|
+
.replace(/"/g, '"')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function renderApp(): Promise<RenderResult> {
|
|
27
|
+
const App = await loadApp()
|
|
28
|
+
if (!App) return { html: NO_APP_HTML }
|
|
29
|
+
|
|
30
|
+
const layoutData = await loadLayout()
|
|
31
|
+
const metadata = layoutData?.metadata
|
|
32
|
+
|
|
33
|
+
// Build the tree: <Layout><App /></Layout> or just <App />
|
|
34
|
+
const appNode = jsx(App as Component, null)
|
|
35
|
+
const tree: Child = layoutData
|
|
36
|
+
? jsx(layoutData.component as Component, { children: appNode })
|
|
37
|
+
: appNode
|
|
38
|
+
|
|
39
|
+
let html = '<!doctype html>' + renderToString(tree)
|
|
40
|
+
|
|
41
|
+
const headInjects: string[] = []
|
|
42
|
+
if (metadata?.title && !/<title>.*?<\/title>/i.test(html)) {
|
|
43
|
+
headInjects.push(`<title>${escapeHtml(metadata.title)}</title>`)
|
|
44
|
+
}
|
|
45
|
+
if (metadata?.description && !/<meta[^>]+name=["']description["']/i.test(html)) {
|
|
46
|
+
headInjects.push(`<meta name="description" content="${escapeHtml(metadata.description)}">`)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const css = loadGlobalsCss()
|
|
50
|
+
if (css) {
|
|
51
|
+
headInjects.push(`<style data-murasaki="globals.css">${css}</style>`)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (headInjects.length) {
|
|
55
|
+
const blob = headInjects.join('')
|
|
56
|
+
if (html.includes('</head>')) {
|
|
57
|
+
html = html.replace('</head>', blob + '</head>')
|
|
58
|
+
} else {
|
|
59
|
+
html = html.replace('<body', blob + '<body')
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { html, metadata }
|
|
64
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Terminal keyboard shortcuts (raw mode stdin).
|
|
2
|
+
//
|
|
3
|
+
// o open the window
|
|
4
|
+
// r restart (close + open)
|
|
5
|
+
// q quit
|
|
6
|
+
// Ctrl+C quit
|
|
7
|
+
|
|
8
|
+
export type ShortcutHandlers = {
|
|
9
|
+
onOpen: () => void
|
|
10
|
+
onRestart: () => void
|
|
11
|
+
onQuit: () => void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function setupShortcuts(handlers: ShortcutHandlers): void {
|
|
15
|
+
if (!process.stdin.isTTY) return
|
|
16
|
+
process.stdin.setRawMode(true)
|
|
17
|
+
process.stdin.resume()
|
|
18
|
+
process.stdin.setEncoding('utf8')
|
|
19
|
+
process.stdin.on('data', (key) => {
|
|
20
|
+
const k = key.toString()
|
|
21
|
+
if (k === 'o' || k === 'O') handlers.onOpen()
|
|
22
|
+
else if (k === 'r' || k === 'R') handlers.onRestart()
|
|
23
|
+
else if (k === 'q' || k === 'Q' || k === '\x03' /* Ctrl+C */) handlers.onQuit()
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function teardownStdin(): void {
|
|
28
|
+
try {
|
|
29
|
+
process.stdin.setRawMode(false)
|
|
30
|
+
} catch {}
|
|
31
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Owns the Application + BrowserWindow lifecycle.
|
|
2
|
+
//
|
|
3
|
+
// The EventLoop is created exactly once (winit/tao limitation). ControlFlow.Wait
|
|
4
|
+
// keeps the loop alive even when no windows are open, so the user can press
|
|
5
|
+
// `o` in the terminal to re-open the window.
|
|
6
|
+
|
|
7
|
+
import { Application, ControlFlow } from '@webviewjs/webview'
|
|
8
|
+
import { printClosed, printError, printHint, printReloaded } from '../cli/log.ts'
|
|
9
|
+
import { DEFAULT_WIN_SIZE, DEFAULT_WIN_TITLE, projectRoot } from '../env.ts'
|
|
10
|
+
import type { Metadata } from '../index.ts'
|
|
11
|
+
import type { WindowConfig } from '../types.ts'
|
|
12
|
+
import { renderApp } from './render.tsx'
|
|
13
|
+
|
|
14
|
+
const config: WindowConfig = {
|
|
15
|
+
title: DEFAULT_WIN_TITLE,
|
|
16
|
+
width: DEFAULT_WIN_SIZE.width,
|
|
17
|
+
height: DEFAULT_WIN_SIZE.height,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const app = new Application({ controlFlow: ControlFlow.Wait })
|
|
21
|
+
let win: ReturnType<typeof app.createBrowserWindow> | null = null
|
|
22
|
+
let webview: ReturnType<NonNullable<typeof win>['createWebview']> | null = null
|
|
23
|
+
|
|
24
|
+
app.onEvent((event) => {
|
|
25
|
+
const kind = event && ((event as any).kind || (event as any).event)
|
|
26
|
+
if (kind === 'window-close-requested') {
|
|
27
|
+
// Dispose so the OS close completes — without this the close button hangs.
|
|
28
|
+
if (win) {
|
|
29
|
+
try {
|
|
30
|
+
win.dispose()
|
|
31
|
+
} catch {}
|
|
32
|
+
}
|
|
33
|
+
win = null
|
|
34
|
+
webview = null
|
|
35
|
+
printClosed()
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
function applyMetadata(metadata?: Metadata) {
|
|
40
|
+
if (!metadata) return
|
|
41
|
+
if (metadata.window?.title) config.title = metadata.window.title
|
|
42
|
+
else if (metadata.title) config.title = metadata.title
|
|
43
|
+
if (metadata.window?.width) config.width = metadata.window.width
|
|
44
|
+
if (metadata.window?.height) config.height = metadata.window.height
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function getConfig(): Readonly<WindowConfig> {
|
|
48
|
+
return config
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function openWindow(): Promise<void> {
|
|
52
|
+
if (win) {
|
|
53
|
+
printHint('Window is already open')
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
const { html, metadata } = await renderApp()
|
|
57
|
+
applyMetadata(metadata)
|
|
58
|
+
win = app.createBrowserWindow({
|
|
59
|
+
title: config.title,
|
|
60
|
+
width: config.width,
|
|
61
|
+
height: config.height,
|
|
62
|
+
})
|
|
63
|
+
webview = win.createWebview({ html })
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function closeWindow(): void {
|
|
67
|
+
if (!win) return
|
|
68
|
+
try {
|
|
69
|
+
win.dispose()
|
|
70
|
+
} catch {}
|
|
71
|
+
win = null
|
|
72
|
+
webview = null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function reloadWindow(triggerFile: string): Promise<void> {
|
|
76
|
+
if (!webview) return
|
|
77
|
+
try {
|
|
78
|
+
const { html } = await renderApp()
|
|
79
|
+
webview.loadHtml(html)
|
|
80
|
+
printReloaded(triggerFile.replace(projectRoot + '/', ''))
|
|
81
|
+
} catch (e: any) {
|
|
82
|
+
printError(`Reload failed: ${e.message}`)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function runApp(): void {
|
|
87
|
+
app.run()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function exitApp(): void {
|
|
91
|
+
try {
|
|
92
|
+
app.exit()
|
|
93
|
+
} catch {}
|
|
94
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Internal shared types (the public Metadata type is re-exported from index.ts).
|
|
2
|
+
|
|
3
|
+
import type { Metadata } from './index.ts'
|
|
4
|
+
import type { Component } from './jsx/types.ts'
|
|
5
|
+
|
|
6
|
+
export type AppComponent = Component
|
|
7
|
+
|
|
8
|
+
export type LayoutModule = {
|
|
9
|
+
component: AppComponent
|
|
10
|
+
metadata?: Metadata
|
|
11
|
+
} | null
|
|
12
|
+
|
|
13
|
+
export type WindowConfig = {
|
|
14
|
+
title: string
|
|
15
|
+
width: number
|
|
16
|
+
height: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type RenderResult = {
|
|
20
|
+
html: string
|
|
21
|
+
metadata?: Metadata
|
|
22
|
+
}
|