murasaki 0.0.3 โ†’ 0.0.4

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 CHANGED
@@ -2,10 +2,9 @@
2
2
  // Murasaki CLI entry point.
3
3
  // Usage: murasaki dev
4
4
 
5
- import 'tsx/esm' // Register the TS+JSX ESM loader
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",
3
+ "version": "0.0.4",
4
4
  "description": "The desktop framework for Next.js developers. Node-powered. WebView-thin. No Rust. No Chromium.",
5
5
  "keywords": [
6
6
  "desktop",
@@ -39,7 +39,11 @@
39
39
  "LICENSE"
40
40
  ],
41
41
  "scripts": {
42
- "example:app-router": "cd examples/app-router && pnpm dev"
42
+ "example:app-router": "cd examples/app-router && pnpm dev",
43
+ "check": "biome check",
44
+ "lint": "biome lint",
45
+ "format": "biome format --write",
46
+ "fix": "biome check --write"
43
47
  },
44
48
  "dependencies": {
45
49
  "@webviewjs/webview": "^0.3.2",
@@ -48,6 +52,8 @@
48
52
  "tsx": "^4.22.4"
49
53
  },
50
54
  "devDependencies": {
55
+ "@biomejs/biome": "^2.5.1",
56
+ "@types/node": "^26.0.1",
51
57
  "@types/react": "^19.2.17",
52
58
  "@types/react-dom": "^19.2.3",
53
59
  "typescript": "^6.0.3"
@@ -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 app/ directory:
5
- // app/layout.tsx (optional)
6
- // app/page.tsx (required)
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><Page /></Layout> with React, ships HTML to the WebView,
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 { Application, ControlFlow } from '@webviewjs/webview'
12
- import { existsSync, readFileSync, watch } from 'node:fs'
13
- import { fileURLToPath, pathToFileURL } from 'node:url'
14
- import { dirname, join } from 'node:path'
15
- import { createElement, type ComponentType, type ReactNode } from 'react'
16
- import { renderToStaticMarkup } from 'react-dom/server'
17
-
18
- // โ”€โ”€ ANSI โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
19
- const BRIGHT = '\x1b[38;2;168;85;247m'
20
- const DIM = '\x1b[38;2;136;136;153m'
21
- const GREEN = '\x1b[38;2;76;175;80m'
22
- const RED = '\x1b[38;2;239;68;68m'
23
- const BOLD = '\x1b[1m'
24
- const RESET = '\x1b[0m'
25
- const noColor = process.env.NO_COLOR || !process.stdout.isTTY
26
- const c = (code: string) => (noColor ? '' : code)
27
-
28
- // โ”€โ”€ Project paths โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
29
- const projectRoot = process.cwd()
30
- const SRC_DIR = join(projectRoot, 'src')
31
- const APP_PATH = join(SRC_DIR, 'app.tsx')
32
- const LAYOUT_PATH = join(SRC_DIR, 'layout.tsx')
33
- const GLOBALS_CSS = join(SRC_DIR, 'globals.css')
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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
- await openWindow()
263
- printReady(Date.now() - START_AT)
264
- setupShortcuts()
265
- setupHmr()
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
- app.run()
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 }
@@ -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 { LayoutModule, ReactComponent } 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<ReactComponent | null> {
17
+ if (!existsSync(APP_PATH)) return null
18
+ const mod = await dynImport(APP_PATH)
19
+ return mod.default as ReactComponent
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 ReactComponent,
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,55 @@
1
+ // Renders <Layout><App /></Layout> to an HTML string and injects
2
+ // metadata (<title>, <meta description>) + src/globals.css.
3
+
4
+ import { createElement } from 'react'
5
+ import { renderToStaticMarkup } from 'react-dom/server'
6
+ import type { RenderResult } from '../types.ts'
7
+ import { loadApp, loadGlobalsCss, loadLayout } from './load.ts'
8
+
9
+ const NO_APP_HTML =
10
+ '<!doctype html><html><body style="font-family:system-ui;padding:40px;">' +
11
+ '<h1 style="color:#A855F7">src/app.tsx not found</h1>' +
12
+ '<p>Create one and the window will reload.</p></body></html>'
13
+
14
+ function escapeHtml(s: string): string {
15
+ return s
16
+ .replace(/&/g, '&amp;')
17
+ .replace(/</g, '&lt;')
18
+ .replace(/>/g, '&gt;')
19
+ .replace(/"/g, '&quot;')
20
+ }
21
+
22
+ export async function renderApp(): Promise<RenderResult> {
23
+ const App = await loadApp()
24
+ if (!App) return { html: NO_APP_HTML }
25
+
26
+ const layoutData = await loadLayout()
27
+ const metadata = layoutData?.metadata
28
+ const appEl = createElement(App)
29
+ const tree = layoutData ? createElement(layoutData.component, null, appEl) : appEl
30
+ let html = '<!doctype html>' + renderToStaticMarkup(tree)
31
+
32
+ const headInjects: string[] = []
33
+ if (metadata?.title && !/<title>.*?<\/title>/i.test(html)) {
34
+ headInjects.push(`<title>${escapeHtml(metadata.title)}</title>`)
35
+ }
36
+ if (metadata?.description && !/<meta[^>]+name=["']description["']/i.test(html)) {
37
+ headInjects.push(`<meta name="description" content="${escapeHtml(metadata.description)}">`)
38
+ }
39
+
40
+ const css = loadGlobalsCss()
41
+ if (css) {
42
+ headInjects.push(`<style data-murasaki="globals.css">${css}</style>`)
43
+ }
44
+
45
+ if (headInjects.length) {
46
+ const blob = headInjects.join('')
47
+ if (html.includes('</head>')) {
48
+ html = html.replace('</head>', blob + '</head>')
49
+ } else {
50
+ html = html.replace('<body', blob + '<body')
51
+ }
52
+ }
53
+
54
+ return { html, metadata }
55
+ }
@@ -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 { ComponentType, ReactNode } from 'react'
4
+ import type { Metadata } from './index.ts'
5
+
6
+ export type ReactComponent = ComponentType<{ children?: ReactNode }>
7
+
8
+ export type LayoutModule = {
9
+ component: ReactComponent
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
+ }