murasaki 0.0.1 → 0.0.3
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/package.json +8 -3
- package/src/dev.tsx +87 -20
- package/src/index.ts +20 -0
- /package/bin/{murasaki.mjs → murasaki.js} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "murasaki",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"description": "The desktop framework for Next.js developers. Node-powered. WebView-thin. No Rust. No Chromium.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"desktop",
|
|
@@ -24,7 +24,13 @@
|
|
|
24
24
|
"author": "ichi",
|
|
25
25
|
"type": "module",
|
|
26
26
|
"bin": {
|
|
27
|
-
"murasaki": "./bin/murasaki.
|
|
27
|
+
"murasaki": "./bin/murasaki.js"
|
|
28
|
+
},
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./src/index.ts",
|
|
32
|
+
"default": "./src/index.ts"
|
|
33
|
+
}
|
|
28
34
|
},
|
|
29
35
|
"files": [
|
|
30
36
|
"bin",
|
|
@@ -33,7 +39,6 @@
|
|
|
33
39
|
"LICENSE"
|
|
34
40
|
],
|
|
35
41
|
"scripts": {
|
|
36
|
-
"example:hello": "MURASAKI_DEV=1 tsx examples/hello/index.tsx",
|
|
37
42
|
"example:app-router": "cd examples/app-router && pnpm dev"
|
|
38
43
|
},
|
|
39
44
|
"dependencies": {
|
package/src/dev.tsx
CHANGED
|
@@ -27,9 +27,10 @@ const c = (code: string) => (noColor ? '' : code)
|
|
|
27
27
|
|
|
28
28
|
// ── Project paths ─────────────────────────────────────────────────────
|
|
29
29
|
const projectRoot = process.cwd()
|
|
30
|
-
const SRC_DIR
|
|
31
|
-
const APP_PATH
|
|
32
|
-
const LAYOUT_PATH
|
|
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')
|
|
33
34
|
|
|
34
35
|
// ── Resolve murasaki version (for the banner) ─────────────────────────
|
|
35
36
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
@@ -48,17 +49,21 @@ const WEBVIEW_ENGINE = (() => {
|
|
|
48
49
|
}
|
|
49
50
|
})()
|
|
50
51
|
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
const START_AT
|
|
54
|
-
const isDev
|
|
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 }
|
|
55
60
|
|
|
56
61
|
// ── Banner ────────────────────────────────────────────────────────────
|
|
57
62
|
function printBanner() {
|
|
58
63
|
process.stdout.write('\n')
|
|
59
64
|
process.stdout.write(` ${c(BOLD)}${c(BRIGHT)}🦋 Murasaki${c(RESET)} ${c(DIM)}${VERSION}${c(RESET)}\n\n`)
|
|
60
65
|
process.stdout.write(` ${c(DIM)}-${c(RESET)} ${c(DIM)}Project ${c(RESET)}${projectRoot}\n`)
|
|
61
|
-
process.stdout.write(` ${c(DIM)}-${c(RESET)} ${c(DIM)}Window ${c(RESET)}${
|
|
66
|
+
process.stdout.write(` ${c(DIM)}-${c(RESET)} ${c(DIM)}Window ${c(RESET)}${winTitle} ${c(DIM)}(${winSize.width}×${winSize.height})${c(RESET)}\n`)
|
|
62
67
|
process.stdout.write(` ${c(DIM)}-${c(RESET)} ${c(DIM)}Webview ${c(RESET)}${WEBVIEW_ENGINE}\n`)
|
|
63
68
|
process.stdout.write(` ${c(DIM)}-${c(RESET)} ${c(DIM)}Runtime ${c(RESET)}Node ${process.version}\n`)
|
|
64
69
|
process.stdout.write(` ${c(DIM)}-${c(RESET)} ${c(DIM)}Mode ${c(RESET)}development ${c(DIM)}(HMR active)${c(RESET)}\n\n`)
|
|
@@ -76,6 +81,14 @@ function printError(msg: string) { process.stdout.write(` ${c(RED)}${c(BOLD)}✗
|
|
|
76
81
|
// ── Routing: load app/page.tsx (+ optional app/layout.tsx) ────────────
|
|
77
82
|
type ReactComponent = ComponentType<{ children?: ReactNode }>
|
|
78
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
|
+
|
|
79
92
|
async function dynImport(path: string) {
|
|
80
93
|
// Cache-bust so file edits are picked up without restarting the process.
|
|
81
94
|
const url = pathToFileURL(path).href + `?v=${Date.now()}`
|
|
@@ -88,21 +101,58 @@ async function loadApp(): Promise<ReactComponent | null> {
|
|
|
88
101
|
return mod.default as ReactComponent
|
|
89
102
|
}
|
|
90
103
|
|
|
91
|
-
async function loadLayout(): Promise<
|
|
104
|
+
async function loadLayout(): Promise<LayoutModule> {
|
|
92
105
|
if (!existsSync(LAYOUT_PATH)) return null
|
|
93
106
|
const mod = await dynImport(LAYOUT_PATH)
|
|
94
|
-
|
|
107
|
+
if (!mod.default) return null
|
|
108
|
+
return { component: mod.default as ReactComponent, metadata: mod.metadata as AppMetadata | undefined }
|
|
95
109
|
}
|
|
96
110
|
|
|
97
|
-
|
|
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 }> {
|
|
98
121
|
const App = await loadApp()
|
|
99
122
|
if (!App) {
|
|
100
|
-
return '<!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>'
|
|
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>' }
|
|
101
124
|
}
|
|
102
|
-
const
|
|
125
|
+
const layoutData = await loadLayout()
|
|
126
|
+
const metadata = layoutData?.metadata
|
|
103
127
|
const appEl = createElement(App)
|
|
104
|
-
const tree =
|
|
105
|
-
|
|
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 }
|
|
106
156
|
}
|
|
107
157
|
|
|
108
158
|
// ── Window lifecycle ──────────────────────────────────────────────────
|
|
@@ -113,20 +163,36 @@ let webview: ReturnType<NonNullable<typeof win>['createWebview']> | null = null
|
|
|
113
163
|
app.onEvent((event) => {
|
|
114
164
|
const kind = event && ((event as any).kind || (event as any).event)
|
|
115
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
|
+
}
|
|
116
171
|
win = null
|
|
117
172
|
webview = null
|
|
118
173
|
printClosed()
|
|
119
174
|
}
|
|
120
175
|
})
|
|
121
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
|
+
|
|
122
185
|
async function openWindow() {
|
|
123
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)
|
|
124
190
|
win = app.createBrowserWindow({
|
|
125
|
-
title:
|
|
126
|
-
width:
|
|
127
|
-
height:
|
|
191
|
+
title: winTitle,
|
|
192
|
+
width: winSize.width,
|
|
193
|
+
height: winSize.height,
|
|
128
194
|
})
|
|
129
|
-
webview = win.createWebview({ html
|
|
195
|
+
webview = win.createWebview({ html })
|
|
130
196
|
printOpened()
|
|
131
197
|
}
|
|
132
198
|
|
|
@@ -140,7 +206,8 @@ function closeWindow() {
|
|
|
140
206
|
async function reload(triggerFile: string) {
|
|
141
207
|
if (!webview) return
|
|
142
208
|
try {
|
|
143
|
-
|
|
209
|
+
const { html } = await renderApp()
|
|
210
|
+
webview.loadHtml(html)
|
|
144
211
|
printReloaded(triggerFile.replace(projectRoot + '/', ''))
|
|
145
212
|
} catch (e: any) {
|
|
146
213
|
printError(`Reload failed: ${e.message}`)
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// murasaki — public API
|
|
2
|
+
//
|
|
3
|
+
// Import like:
|
|
4
|
+
// import type { Metadata } from 'murasaki'
|
|
5
|
+
|
|
6
|
+
export type Metadata = {
|
|
7
|
+
/** Default <title> for the app (overridden by <title> tag inside <head>) */
|
|
8
|
+
title?: string
|
|
9
|
+
/** <meta name="description"> */
|
|
10
|
+
description?: string
|
|
11
|
+
/** Initial window options (applied at first open; user can resize after) */
|
|
12
|
+
window?: {
|
|
13
|
+
/** Window title bar text (defaults to metadata.title) */
|
|
14
|
+
title?: string
|
|
15
|
+
/** Initial width in logical pixels */
|
|
16
|
+
width?: number
|
|
17
|
+
/** Initial height in logical pixels */
|
|
18
|
+
height?: number
|
|
19
|
+
}
|
|
20
|
+
}
|
|
File without changes
|