murasaki 0.1.0 → 0.2.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.1.0",
3
+ "version": "0.2.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",
@@ -0,0 +1,25 @@
1
+ // <Link href="/about">About</Link>
2
+ //
3
+ // Emits a plain <a> tagged with data-murasaki-link. The dev runner injects
4
+ // a tiny script that intercepts clicks on these and switches the visible
5
+ // route block in place — no full reload, no flash.
6
+
7
+ import { jsx } from '../jsx/runtime.ts'
8
+ import type { Child } from '../jsx/types.ts'
9
+
10
+ export type LinkProps = {
11
+ href: string
12
+ children?: Child
13
+ className?: string
14
+ // Pass-through anchor props
15
+ [key: string]: unknown
16
+ }
17
+
18
+ export function Link({ href, children, ...rest }: LinkProps) {
19
+ return jsx('a', {
20
+ href: `#${href}`,
21
+ 'data-murasaki-link': href,
22
+ ...rest,
23
+ children,
24
+ })
25
+ }
package/src/env.ts CHANGED
@@ -6,9 +6,20 @@ import { fileURLToPath } from 'node:url'
6
6
 
7
7
  export const projectRoot = process.cwd()
8
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')
9
+
10
+ // App-router convention (new, preferred):
11
+ // src/app/page.tsx → "/"
12
+ // src/app/layout.tsx → root layout (html/head/body)
13
+ // src/app/<sub>/page.tsx → "/<sub>"
14
+ // src/app/globals.css → auto-injected
15
+ export const APP_DIR = join(SRC_DIR, 'app')
16
+ export const APP_GLOBALS_CSS = join(APP_DIR, 'globals.css')
17
+
18
+ // Legacy single-page convention (still supported):
19
+ // src/app.tsx + src/layout.tsx + src/globals.css
20
+ export const LEGACY_APP_PATH = join(SRC_DIR, 'app.tsx')
21
+ export const LEGACY_LAYOUT_PATH = join(SRC_DIR, 'layout.tsx')
22
+ export const LEGACY_GLOBALS_CSS = join(SRC_DIR, 'globals.css')
12
23
 
13
24
  const __dirname = dirname(fileURLToPath(import.meta.url))
14
25
  export const VERSION: string = (() => {
package/src/index.ts CHANGED
@@ -2,6 +2,10 @@
2
2
  //
3
3
  // Import like:
4
4
  // import type { Metadata } from 'murasaki'
5
+ // import { Link } from 'murasaki'
6
+
7
+ export type { LinkProps } from './components/Link.tsx'
8
+ export { Link } from './components/Link.tsx'
5
9
 
6
10
  export type Metadata = {
7
11
  /** Default <title> for the app (overridden by <title> tag inside <head>) */
package/src/jsx/index.ts CHANGED
@@ -7,6 +7,7 @@ export {
7
7
  isValidElement,
8
8
  JSXNode,
9
9
  jsx,
10
+ raw,
10
11
  renderToString,
11
12
  } from './runtime.ts'
12
13
 
@@ -178,6 +178,26 @@ function renderAttrs(props: Props): string {
178
178
  return out
179
179
  }
180
180
 
181
+ // ── Raw HTML escape hatch (pre-rendered HTML as a child) ─────────────
182
+ class RawHtml {
183
+ readonly __isJSXNode = true as const
184
+ tag = '__raw__'
185
+ props: Props = {}
186
+ children: Child[] = []
187
+ html: string
188
+ constructor(html: string) {
189
+ this.html = html
190
+ }
191
+ toString(): string {
192
+ return this.html
193
+ }
194
+ }
195
+
196
+ /** Wrap pre-rendered HTML so it's emitted verbatim as a JSX child. */
197
+ export function raw(html: string): JSXNodeLike {
198
+ return new RawHtml(html)
199
+ }
200
+
181
201
  // ── Children rendering ───────────────────────────────────────────────
182
202
  function renderChild(c: Child): string {
183
203
  if (c == null || c === false || c === true) return ''
@@ -1,19 +1,35 @@
1
- // Renders <Layout><App /></Layout> to an HTML string and injects
2
- // metadata (<title>, <meta description>) + src/globals.css.
1
+ // Multi-route renderer.
3
2
  //
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
+ // Pipeline:
4
+ // 1. discover all src/app/<...>/page.tsx
5
+ // 2. render each page wrapped in its nested layouts (NOT the root layout)
6
+ // 3. render the root layout once with a switcher block as its children
7
+ // 4. inject metadata (<title>, <meta description>) + globals.css into <head>
8
+ // 5. inject a tiny navigation script that listens to hash changes and
9
+ // intercepts <Link> clicks
10
+ //
11
+ // Legacy single-page (src/app.tsx + src/layout.tsx) is still supported:
12
+ // if src/app/ doesn't exist, we render the legacy convention.
7
13
 
8
- import { jsx, renderToString } from '../jsx/runtime.ts'
14
+ import { existsSync, readFileSync } from 'node:fs'
15
+ import { pathToFileURL } from 'node:url'
16
+ import {
17
+ APP_DIR,
18
+ APP_GLOBALS_CSS,
19
+ LEGACY_APP_PATH,
20
+ LEGACY_GLOBALS_CSS,
21
+ LEGACY_LAYOUT_PATH,
22
+ } from '../env.ts'
23
+ import type { Metadata } from '../index.ts'
24
+ import { jsx, raw, renderToString } from '../jsx/runtime.ts'
9
25
  import type { Child, Component } from '../jsx/types.ts'
10
26
  import type { RenderResult } from '../types.ts'
11
- import { loadApp, loadGlobalsCss, loadLayout } from './load.ts'
27
+ import { discoverRoutes, type Route } from './routes.ts'
12
28
 
13
29
  const NO_APP_HTML =
14
30
  '<!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>'
31
+ '<h1 style="color:#A855F7">No app found</h1>' +
32
+ '<p>Create <code>src/app/page.tsx</code> and the window will reload.</p></body></html>'
17
33
 
18
34
  function escapeHtml(s: string): string {
19
35
  return s
@@ -23,21 +39,119 @@ function escapeHtml(s: string): string {
23
39
  .replace(/"/g, '&quot;')
24
40
  }
25
41
 
26
- export async function renderApp(): Promise<RenderResult> {
27
- const App = await loadApp()
28
- if (!App) return { html: NO_APP_HTML }
42
+ async function dynImport(path: string) {
43
+ const url = pathToFileURL(path).href + `?v=${Date.now()}`
44
+ return import(url)
45
+ }
46
+
47
+ type Loaded = {
48
+ default: Component
49
+ metadata?: Metadata
50
+ }
51
+
52
+ async function loadModule(path: string): Promise<Loaded | null> {
53
+ if (!existsSync(path)) return null
54
+ const mod = await dynImport(path)
55
+ if (!mod.default) return null
56
+ return mod
57
+ }
58
+
59
+ // ── Page rendering ──────────────────────────────────────────────────
60
+ async function renderPageInner(route: Route, rootLayoutFile: string | null): Promise<string> {
61
+ const pageMod = await loadModule(route.pageFile)
62
+ if (!pageMod) return ''
63
+ let tree: Child = jsx(pageMod.default, null)
64
+
65
+ // Wrap in nested layouts, innermost first (i.e. skip root layout — it wraps everything later)
66
+ // route.layoutFiles: outermost first → so iterate from end backwards excluding root
67
+ const layoutsToApply = route.layoutFiles.filter((f) => f !== rootLayoutFile)
68
+ // Apply from innermost (last in array) outward (first in array) so the
69
+ // outer layout wraps the inner: <Outer><Inner><Page/></Inner></Outer>
70
+ for (let i = layoutsToApply.length - 1; i >= 0; i--) {
71
+ const layoutMod = await loadModule(layoutsToApply[i])
72
+ if (!layoutMod) continue
73
+ tree = jsx(layoutMod.default, { children: tree })
74
+ }
75
+ return renderToString(tree)
76
+ }
29
77
 
30
- const layoutData = await loadLayout()
31
- const metadata = layoutData?.metadata
78
+ // ── Root layout + metadata ──────────────────────────────────────────
79
+ async function renderRootLayout(rootLayout: Loaded | null, body: Child): Promise<string> {
80
+ if (!rootLayout) {
81
+ // Fallback root if user didn't write src/app/layout.tsx
82
+ const fallback = jsx('html', {
83
+ lang: 'en',
84
+ children: [
85
+ jsx('head', { children: jsx('meta', { charSet: 'utf-8' }) }),
86
+ jsx('body', { children: body }),
87
+ ],
88
+ })
89
+ return renderToString(fallback)
90
+ }
91
+ const tree = jsx(rootLayout.default, { children: body })
92
+ return renderToString(tree)
93
+ }
32
94
 
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
95
+ // ── Navigation script (injected once) ───────────────────────────────
96
+ const NAV_SCRIPT = `
97
+ <script>
98
+ (function(){
99
+ function showRoute(path){
100
+ var blocks=document.querySelectorAll('[data-murasaki-route]');
101
+ var matched=false;
102
+ for(var i=0;i<blocks.length;i++){
103
+ var b=blocks[i];
104
+ if(b.getAttribute('data-murasaki-route')===path){
105
+ b.removeAttribute('hidden');matched=true;
106
+ } else {
107
+ b.setAttribute('hidden','');
108
+ }
109
+ }
110
+ if(!matched){
111
+ // fallback to "/"
112
+ var root=document.querySelector('[data-murasaki-route="/"]');
113
+ if(root)root.removeAttribute('hidden');
114
+ }
115
+ document.dispatchEvent(new CustomEvent('murasaki:navigate',{detail:{path:path}}));
116
+ }
117
+ function currentPath(){
118
+ var h=location.hash||'';
119
+ return h.charAt(0)==='#'?h.slice(1)||'/':'/'
120
+ }
121
+ window.addEventListener('hashchange',function(){showRoute(currentPath())});
122
+ document.addEventListener('click',function(e){
123
+ var t=e.target;
124
+ while(t&&t.nodeType===1){
125
+ if(t.tagName==='A'&&t.hasAttribute('data-murasaki-link')){
126
+ e.preventDefault();
127
+ var href=t.getAttribute('data-murasaki-link');
128
+ if('#'+href!==location.hash){location.hash=href;}
129
+ else{showRoute(href);}
130
+ return;
131
+ }
132
+ t=t.parentNode;
133
+ }
134
+ });
135
+ // Initial render
136
+ showRoute(currentPath());
137
+ })();
138
+ </script>
139
+ `.trim()
38
140
 
39
- let html = '<!doctype html>' + renderToString(tree)
141
+ // ── Globals.css discovery (app/ takes precedence over src/) ─────────
142
+ function loadGlobalsCss(): string {
143
+ for (const p of [APP_GLOBALS_CSS, LEGACY_GLOBALS_CSS]) {
144
+ if (existsSync(p)) {
145
+ try {
146
+ return readFileSync(p, 'utf8')
147
+ } catch {}
148
+ }
149
+ }
150
+ return ''
151
+ }
40
152
 
153
+ // ── Head injection ──────────────────────────────────────────────────
154
+ function injectHead(html: string, metadata: Metadata | undefined, css: string): string {
41
155
  const headInjects: string[] = []
42
156
  if (metadata?.title && !/<title>.*?<\/title>/i.test(html)) {
43
157
  headInjects.push(`<title>${escapeHtml(metadata.title)}</title>`)
@@ -45,20 +159,67 @@ export async function renderApp(): Promise<RenderResult> {
45
159
  if (metadata?.description && !/<meta[^>]+name=["']description["']/i.test(html)) {
46
160
  headInjects.push(`<meta name="description" content="${escapeHtml(metadata.description)}">`)
47
161
  }
48
-
49
- const css = loadGlobalsCss()
50
162
  if (css) {
51
163
  headInjects.push(`<style data-murasaki="globals.css">${css}</style>`)
52
164
  }
165
+ if (!headInjects.length) return html
166
+ const blob = headInjects.join('')
167
+ if (html.includes('</head>')) return html.replace('</head>', blob + '</head>')
168
+ return html.replace('<body', blob + '<body')
169
+ }
53
170
 
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
- }
171
+ // ── Main entry ──────────────────────────────────────────────────────
172
+ export async function renderApp(): Promise<RenderResult> {
173
+ // 1. Try app-router convention first
174
+ if (existsSync(APP_DIR)) {
175
+ return renderAppRouter()
176
+ }
177
+ // 2. Fall back to legacy single-page
178
+ if (existsSync(LEGACY_APP_PATH)) {
179
+ return renderLegacy()
180
+ }
181
+ return { html: NO_APP_HTML }
182
+ }
183
+
184
+ async function renderAppRouter(): Promise<RenderResult> {
185
+ const routes = discoverRoutes(APP_DIR)
186
+ if (routes.length === 0) return { html: NO_APP_HTML }
187
+
188
+ // Identify root layout (src/app/layout.tsx) if any
189
+ const rootLayoutPath = routes[0]?.layoutFiles[0]
190
+ const rootLayoutFile =
191
+ rootLayoutPath && rootLayoutPath.endsWith('/app/layout.tsx') ? rootLayoutPath : null
192
+ const rootLayoutMod = rootLayoutFile ? await loadModule(rootLayoutFile) : null
193
+ const metadata = rootLayoutMod?.metadata
194
+
195
+ // 2. Render each page (wrapped in its nested layouts, excluding root)
196
+ const blocks: string[] = []
197
+ for (const route of routes) {
198
+ const inner = await renderPageInner(route, rootLayoutFile)
199
+ blocks.push(`<div data-murasaki-route="${escapeHtml(route.path)}" hidden>${inner}</div>`)
61
200
  }
201
+ const switcher = raw(blocks.join('') + NAV_SCRIPT)
202
+
203
+ // 3. Render root layout with switcher as children
204
+ const bodyContent = await renderRootLayout(rootLayoutMod, switcher)
205
+ let html = '<!doctype html>' + bodyContent
206
+
207
+ // 4. Inject metadata + globals.css
208
+ html = injectHead(html, metadata, loadGlobalsCss())
209
+
210
+ return { html, metadata }
211
+ }
62
212
 
213
+ // ── Legacy single-page render (unchanged shape) ─────────────────────
214
+ async function renderLegacy(): Promise<RenderResult> {
215
+ const pageMod = await loadModule(LEGACY_APP_PATH)
216
+ if (!pageMod) return { html: NO_APP_HTML }
217
+ const layoutMod = await loadModule(LEGACY_LAYOUT_PATH)
218
+ const metadata = layoutMod?.metadata
219
+ const body: Child = layoutMod
220
+ ? jsx(layoutMod.default, { children: jsx(pageMod.default, null) })
221
+ : jsx(pageMod.default, null)
222
+ let html = '<!doctype html>' + renderToString(body)
223
+ html = injectHead(html, metadata, loadGlobalsCss())
63
224
  return { html, metadata }
64
225
  }
@@ -0,0 +1,73 @@
1
+ // File-based route discovery for src/app/.
2
+ //
3
+ // Conventions (subset of Next.js app router):
4
+ // src/app/page.tsx → "/"
5
+ // src/app/layout.tsx → root layout (wraps everything)
6
+ // src/app/about/page.tsx → "/about"
7
+ // src/app/about/layout.tsx → nested layout for /about and its children
8
+ // src/app/_foo/ → ignored (leading underscore = private)
9
+ //
10
+ // A page.tsx is required to register a route. A layout.tsx without a
11
+ // page.tsx in the same dir just contributes to children's layouts.
12
+
13
+ import { existsSync, readdirSync, statSync } from 'node:fs'
14
+ import { join } from 'node:path'
15
+
16
+ export type Route = {
17
+ /** URL-style path: "/", "/about", "/settings/profile" */
18
+ path: string
19
+ /** Absolute path to the page.tsx file */
20
+ pageFile: string
21
+ /** Absolute paths to layout.tsx files, OUTERMOST first (root → innermost) */
22
+ layoutFiles: string[]
23
+ }
24
+
25
+ export function discoverRoutes(appDir: string): Route[] {
26
+ if (!existsSync(appDir)) return []
27
+ const out: Route[] = []
28
+ walk(appDir, '', [], out)
29
+ // Stable sort so "/" comes first, then alphabetical
30
+ out.sort((a, b) => {
31
+ if (a.path === '/') return -1
32
+ if (b.path === '/') return 1
33
+ return a.path.localeCompare(b.path)
34
+ })
35
+ return out
36
+ }
37
+
38
+ function walk(dir: string, routePath: string, layouts: string[], out: Route[]): void {
39
+ let entries: string[]
40
+ try {
41
+ entries = readdirSync(dir)
42
+ } catch {
43
+ return
44
+ }
45
+
46
+ const hasLayout = entries.includes('layout.tsx')
47
+ const hasPage = entries.includes('page.tsx')
48
+
49
+ // The current directory contributes its layout to itself and descendants.
50
+ const ownLayouts = hasLayout ? [...layouts, join(dir, 'layout.tsx')] : layouts
51
+
52
+ if (hasPage) {
53
+ out.push({
54
+ path: routePath || '/',
55
+ pageFile: join(dir, 'page.tsx'),
56
+ layoutFiles: ownLayouts,
57
+ })
58
+ }
59
+
60
+ // Recurse into subdirectories (skip files, hidden, private "_*", node_modules).
61
+ for (const entry of entries) {
62
+ if (entry.startsWith('.') || entry.startsWith('_') || entry === 'node_modules') continue
63
+ const full = join(dir, entry)
64
+ let isDir = false
65
+ try {
66
+ isDir = statSync(full).isDirectory()
67
+ } catch {
68
+ continue
69
+ }
70
+ if (!isDir) continue
71
+ walk(full, `${routePath}/${entry}`, ownLayouts, out)
72
+ }
73
+ }
@@ -1,39 +0,0 @@
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
- }