murasaki 0.0.4 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "murasaki",
3
- "version": "0.0.4",
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": [
@@ -47,15 +59,11 @@
47
59
  },
48
60
  "dependencies": {
49
61
  "@webviewjs/webview": "^0.3.2",
50
- "react": "^19.2.7",
51
- "react-dom": "^19.2.7",
52
62
  "tsx": "^4.22.4"
53
63
  },
54
64
  "devDependencies": {
55
65
  "@biomejs/biome": "^2.5.1",
56
66
  "@types/node": "^26.0.1",
57
- "@types/react": "^19.2.17",
58
- "@types/react-dom": "^19.2.3",
59
67
  "typescript": "^6.0.3"
60
68
  }
61
69
  }
@@ -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, '&amp;').replace(LT, '&lt;').replace(GT, '&gt;')
26
+ }
27
+
28
+ function escapeAttr(s: string): string {
29
+ return s.replace(AMP, '&amp;').replace(QT, '&quot;')
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
+ }
@@ -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
+ }
@@ -6,17 +6,17 @@ import { existsSync, readFileSync } from 'node:fs'
6
6
  import { pathToFileURL } from 'node:url'
7
7
  import { APP_PATH, GLOBALS_CSS, LAYOUT_PATH } from '../env.ts'
8
8
  import type { Metadata } from '../index.ts'
9
- import type { LayoutModule, ReactComponent } from '../types.ts'
9
+ import type { AppComponent, LayoutModule } from '../types.ts'
10
10
 
11
11
  async function dynImport(path: string) {
12
12
  const url = pathToFileURL(path).href + `?v=${Date.now()}`
13
13
  return import(url)
14
14
  }
15
15
 
16
- export async function loadApp(): Promise<ReactComponent | null> {
16
+ export async function loadApp(): Promise<AppComponent | null> {
17
17
  if (!existsSync(APP_PATH)) return null
18
18
  const mod = await dynImport(APP_PATH)
19
- return mod.default as ReactComponent
19
+ return mod.default as AppComponent
20
20
  }
21
21
 
22
22
  export async function loadLayout(): Promise<LayoutModule> {
@@ -24,7 +24,7 @@ export async function loadLayout(): Promise<LayoutModule> {
24
24
  const mod = await dynImport(LAYOUT_PATH)
25
25
  if (!mod.default) return null
26
26
  return {
27
- component: mod.default as ReactComponent,
27
+ component: mod.default as AppComponent,
28
28
  metadata: mod.metadata as Metadata | undefined,
29
29
  }
30
30
  }
@@ -1,8 +1,12 @@
1
1
  // Renders <Layout><App /></Layout> to an HTML string and injects
2
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().
3
7
 
4
- import { createElement } from 'react'
5
- import { renderToStaticMarkup } from 'react-dom/server'
8
+ import { jsx, renderToString } from '../jsx/runtime.ts'
9
+ import type { Child, Component } from '../jsx/types.ts'
6
10
  import type { RenderResult } from '../types.ts'
7
11
  import { loadApp, loadGlobalsCss, loadLayout } from './load.ts'
8
12
 
@@ -25,9 +29,14 @@ export async function renderApp(): Promise<RenderResult> {
25
29
 
26
30
  const layoutData = await loadLayout()
27
31
  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)
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)
31
40
 
32
41
  const headInjects: string[] = []
33
42
  if (metadata?.title && !/<title>.*?<\/title>/i.test(html)) {
package/src/types.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  // Internal shared types (the public Metadata type is re-exported from index.ts).
2
2
 
3
- import type { ComponentType, ReactNode } from 'react'
4
3
  import type { Metadata } from './index.ts'
4
+ import type { Component } from './jsx/types.ts'
5
5
 
6
- export type ReactComponent = ComponentType<{ children?: ReactNode }>
6
+ export type AppComponent = Component
7
7
 
8
8
  export type LayoutModule = {
9
- component: ReactComponent
9
+ component: AppComponent
10
10
  metadata?: Metadata
11
11
  } | null
12
12