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 +1 -1
- package/src/components/Link.tsx +25 -0
- package/src/env.ts +14 -3
- package/src/index.ts +4 -0
- package/src/jsx/index.ts +1 -0
- package/src/jsx/runtime.ts +20 -0
- package/src/runtime/render.tsx +190 -29
- package/src/runtime/routes.ts +73 -0
- package/src/runtime/load.ts +0 -39
package/package.json
CHANGED
|
@@ -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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
package/src/jsx/runtime.ts
CHANGED
|
@@ -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 ''
|
package/src/runtime/render.tsx
CHANGED
|
@@ -1,19 +1,35 @@
|
|
|
1
|
-
//
|
|
2
|
-
// metadata (<title>, <meta description>) + src/globals.css.
|
|
1
|
+
// Multi-route renderer.
|
|
3
2
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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 {
|
|
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 {
|
|
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">
|
|
16
|
-
'<p>Create
|
|
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, '"')
|
|
24
40
|
}
|
|
25
41
|
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
+
}
|
package/src/runtime/load.ts
DELETED
|
@@ -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
|
-
}
|