Skip to content

Commit f35caeb

Browse files
refactor(devtools): migrate to @extends + @stxRouter SPA pattern
Replace custom app shell composition with the recommended stx v2 pattern: @extends layout with @stxRouter('main') directive. Pages use @section('content') with <script client> inside for scope isolation. Server returns <main> content as fragment for SPA navigation with X-STX-Fragment header. - Restore src/layouts/app.stx with @stxRouter('main') - Delete src/app.stx (custom shell no longer needed) - Simplify src/index.ts — processDirectives handles full rendering, fragment extraction for SPA uses <main> content - All 12 pages use @extends + @section with script inside content Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 93d529a commit f35caeb

File tree

15 files changed

+1147
-1128
lines changed

15 files changed

+1147
-1128
lines changed

packages/devtools/src/app.stx

Lines changed: 0 additions & 36 deletions
This file was deleted.

packages/devtools/src/index.ts

Lines changed: 39 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { DashboardConfig } from './types'
22
import path from 'node:path'
3-
import { defaultConfig as stxDefaultConfig, generateSignalsRuntime, injectRouterScript, isSpaNavigation, processDirectives, stripDocumentWrapper } from '@stacksjs/stx'
3+
import { defaultConfig as stxDefaultConfig, isSpaNavigation, processDirectives, stripDocumentWrapper } from '@stacksjs/stx'
44
import { BroadcastServer } from 'ts-broadcasting'
55
import { createApiRoutes, fetchBatchById, fetchBatches, fetchDashboardStats, fetchDependencyGraph, fetchJobById, fetchJobGroups, fetchJobs, fetchMetrics, fetchQueueById, fetchQueues } from './api'
66
import { resolveConfig } from './api'
@@ -12,36 +12,15 @@ export { createApiRoutes, fetchBatches, fetchDashboardStats, fetchDependencyGrap
1212
const SRC_DIR = import.meta.dir
1313
const PAGES_DIR = path.join(SRC_DIR, 'pages')
1414
const FUNCTIONS_ENTRY = path.join(SRC_DIR, 'functions', 'browser.ts')
15-
const SHELL_PATH = path.join(SRC_DIR, 'app.stx')
1615

1716
let broadcastServer: BroadcastServer | null = null
1817
let bundledFunctionsJs: string | null = null
19-
let cachedShell: { before: string, after: string, styles: string, scripts: string } | null = null
20-
let cachedRouterScript: string | null = null
21-
22-
function getRouterScriptTag(): string {
23-
if (cachedRouterScript) return cachedRouterScript
24-
// Extract the router script by injecting into a minimal HTML doc
25-
const minimal = '<!DOCTYPE html><html><head></head><body></body></html>'
26-
const injected = injectRouterScript(minimal)
27-
const match = injected.match(/<script>[\s\S]*?__stxRouter[\s\S]*?<\/script>/)
28-
cachedRouterScript = match ? match[0] : ''
29-
return cachedRouterScript
30-
}
3118

32-
const pageTitles: Record<string, string> = {
33-
'index': 'bun-queue Dashboard',
34-
'monitoring': 'Real-time Monitoring — bun-queue',
35-
'metrics': 'Performance Metrics — bun-queue',
36-
'queues': 'Queues — bun-queue',
37-
'queue-details': 'Queue Details — bun-queue',
38-
'jobs': 'Jobs — bun-queue',
39-
'job-details': 'Job Details — bun-queue',
40-
'batches': 'Batches — bun-queue',
41-
'batch-details': 'Batch Details — bun-queue',
42-
'groups': 'Job Groups — bun-queue',
43-
'group-details': 'Group Details — bun-queue',
44-
'dependencies': 'Job Dependencies — bun-queue',
19+
const stxConfig = {
20+
...stxDefaultConfig,
21+
componentsDir: path.join(SRC_DIR, 'components'),
22+
layoutsDir: path.join(SRC_DIR, 'layouts'),
23+
partialsDir: path.join(SRC_DIR, 'partials'),
4524
}
4625

4726
async function buildFunctionsBundle(): Promise<string> {
@@ -64,87 +43,46 @@ async function buildFunctionsBundle(): Promise<string> {
6443
return bundledFunctionsJs
6544
}
6645

67-
const stxConfig = {
68-
...stxDefaultConfig,
69-
componentsDir: path.join(SRC_DIR, 'components'),
70-
partialsDir: path.join(SRC_DIR, 'partials'),
71-
}
72-
73-
async function getShellParts(): Promise<{ before: string, after: string, styles: string, scripts: string, signalsRuntime: string }> {
74-
if (cachedShell) return cachedShell
75-
76-
const shellContent = await Bun.file(SHELL_PATH).text()
77-
78-
// Extract <template> block
79-
const templateMatch = shellContent.match(/<template\b[^>]*>([\s\S]*?)<\/template>/i)
80-
let shellTemplate = templateMatch ? templateMatch[1].trim() : shellContent
81-
82-
// Extract <style> blocks from the full file
83-
const styles = (shellContent.match(/<style\b[^>]*>[\s\S]*?<\/style>/gi) || []).join('\n')
84-
85-
// Extract <script client> blocks, process them through stx for TypeScript transpilation
86-
const clientScriptMatches = shellContent.match(/<script\b[^>]*\bclient\b[^>]*>[\s\S]*?<\/script>/gi) || []
87-
let scripts = ''
88-
if (clientScriptMatches.length > 0) {
89-
const scriptHtml = clientScriptMatches.join('\n')
90-
const scriptContext = { __filename: SHELL_PATH, __dirname: path.dirname(SHELL_PATH) }
91-
// Skip runtime here too — we extract it separately
92-
scripts = await processDirectives(scriptHtml, scriptContext, SHELL_PATH, { ...stxConfig, skipSignalsRuntime: true }, new Set())
93-
scripts = stripDocumentWrapper(scripts)
94-
}
95-
96-
// Remove scripts and styles from template for processing
97-
shellTemplate = shellTemplate.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '')
98-
shellTemplate = shellTemplate.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '')
99-
100-
// Replace <slot /> with placeholder
101-
const SLOT = '<!--__STX_SLOT__-->'
102-
shellTemplate = shellTemplate.replace(/<slot\s*\/>/gi, SLOT).replace(/<slot\s*>\s*<\/slot>/gi, SLOT)
103-
104-
// Process shell template WITHOUT signals runtime — we'll place it in <head> ourselves
105-
const context = { __filename: SHELL_PATH, __dirname: path.dirname(SHELL_PATH) }
106-
let processed = await processDirectives(shellTemplate, context, SHELL_PATH, { ...stxConfig, skipSignalsRuntime: true }, new Set())
107-
processed = stripDocumentWrapper(processed)
108-
109-
// Get the signals runtime directly
110-
const signalsRuntime = `<script data-stx-scoped>${generateSignalsRuntime()}</script>`
111-
112-
const slotIdx = processed.indexOf(SLOT)
113-
if (slotIdx === -1) {
114-
// eslint-disable-next-line no-console
115-
console.warn('[bq-devtools] Shell has no <slot /> — falling back')
116-
cachedShell = { before: '', after: '', styles, scripts, signalsRuntime }
117-
return cachedShell
118-
}
119-
120-
cachedShell = {
121-
before: processed.substring(0, slotIdx),
122-
after: processed.substring(slotIdx + SLOT.length),
123-
styles,
124-
scripts,
125-
signalsRuntime,
126-
}
127-
return cachedShell
128-
}
129-
13046
async function renderStxPage(templateName: string, wsUrl: string, req: Request): Promise<Response> {
13147
const templatePath = path.join(PAGES_DIR, `${templateName}.stx`)
13248
const content = await Bun.file(templatePath).text()
13349

134-
const context = { __filename: templatePath, __dirname: path.dirname(templatePath) }
135-
let pageHtml = await processDirectives(content, context, templatePath, stxConfig, new Set())
136-
pageHtml = stripDocumentWrapper(pageHtml)
137-
138-
// Strip the signals runtime from page fragment — the shell already provides it in <head>
139-
pageHtml = pageHtml.replace(/<script data-stx-scoped>\(function\(\)\{'use strict';var cloakStyle[\s\S]*?<\/script>/, '')
50+
const context: Record<string, any> = { __filename: templatePath, __dirname: path.dirname(templatePath) }
51+
let html = await processDirectives(content, context, templatePath, stxConfig, new Set())
14052

141-
// Extract the SFC setup function name so we can bind it to the content wrapper
142-
const setupMatch = pageHtml.match(/function (__stx_setup_\w+)/)
143-
const pageSetupName = setupMatch ? setupMatch[1] : null
53+
// Inject WebSocket URL for real-time updates
54+
html = html.replace('</head>', `<script>window.__BQ_WS_URL = "${wsUrl}";</script>\n</head>`)
14455

145-
// SPA navigation — return fragment only
56+
// SPA navigation — extract <main> content as fragment
14657
if (isSpaNavigation(req)) {
147-
return new Response(pageHtml, {
58+
let fragment = ''
59+
60+
// Extract <head> styles (page-specific styles from @push('styles'))
61+
const headMatch = html.match(/<head\b[^>]*>([\s\S]*?)<\/head>/i)
62+
if (headMatch) {
63+
const headContent = headMatch[1]
64+
const styleRegex = /<style\b[^>]*>[\s\S]*?<\/style>/gi
65+
let m: RegExpExecArray | null
66+
while ((m = styleRegex.exec(headContent)) !== null) {
67+
fragment += m[0] + '\n'
68+
}
69+
}
70+
71+
// Extract <main> inner content
72+
const mainOpenMatch = html.match(/<main\b[^>]*>/i)
73+
const mainCloseIdx = html.lastIndexOf('</main>')
74+
if (mainOpenMatch && mainCloseIdx !== -1) {
75+
const mainStart = mainOpenMatch.index! + mainOpenMatch[0].length
76+
fragment += html.slice(mainStart, mainCloseIdx)
77+
}
78+
79+
// Strip the signals runtime IIFE (shell already has it)
80+
fragment = fragment.replace(
81+
/<script data-stx-scoped>\s*;?\(function\(\)\s*\{[\s\S]*?<\/script>/g,
82+
'',
83+
)
84+
85+
return new Response(fragment, {
14886
headers: {
14987
'Content-Type': 'text/html',
15088
'Cache-Control': 'no-store',
@@ -153,30 +91,6 @@ async function renderStxPage(templateName: string, wsUrl: string, req: Request):
15391
})
15492
}
15593

156-
// Full page request — compose with shell
157-
const shell = await getShellParts()
158-
const title = pageTitles[templateName] || 'bun-queue'
159-
160-
const html = `<!DOCTYPE html>
161-
<html lang="en">
162-
<head>
163-
<meta charset="UTF-8">
164-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
165-
<title>${title}</title>
166-
<script src="/bq-utils.js"><\/script>
167-
<script>window.__BQ_WS_URL = "${wsUrl}";window.__stxRouterConfig={container:'[data-stx-content]'};<\/script>
168-
${shell.styles}
169-
${shell.signalsRuntime}
170-
</head>
171-
<body class="bg-[#0a0a0f] text-zinc-50 leading-relaxed min-h-screen">
172-
${shell.before}
173-
<div data-stx-content${pageSetupName ? ` data-stx="${pageSetupName}"` : ''}>${pageHtml}</div>
174-
${shell.after}
175-
${shell.scripts}
176-
${getRouterScriptTag()}
177-
</body>
178-
</html>`
179-
18094
return new Response(html, { headers: { 'Content-Type': 'text/html', 'Cache-Control': 'no-store' } })
18195
}
18296

@@ -234,9 +148,6 @@ export async function serveDashboard(options: DashboardConfig = {}): Promise<voi
234148
const config = resolveConfig(options)
235149
const apiRoutes = createApiRoutes(config)
236150

237-
// Pre-process the app shell at startup
238-
await getShellParts()
239-
240151
// Start WebSocket broadcast server for real-time updates
241152
const broadcastPort = options.broadcastPort || 6001
242153
broadcastServer = new BroadcastServer({
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<script client>
2+
const collapsed = useLocalStorage('sidebar-collapsed', false)
3+
4+
function toggle() {
5+
collapsed.set(!collapsed())
6+
}
7+
</script>
8+
9+
<!DOCTYPE html>
10+
<html lang="en">
11+
<head>
12+
<meta charset="UTF-8">
13+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
14+
<title>@yield('title', 'bun-queue')</title>
15+
<script src="/bq-utils.js"></script>
16+
@stack('head')
17+
<style>
18+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
19+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }
20+
21+
/* Sidebar collapsed state overrides */
22+
.sidebar.collapsed { width: 56px !important; }
23+
.sidebar.collapsed .sidebar-brand-text { display: none; }
24+
.sidebar.collapsed .sidebar-toggle-btn { margin-left: auto; margin-right: auto; }
25+
.sidebar.collapsed .nav-label { opacity: 0; height: 0; padding: 0; overflow: hidden; }
26+
.sidebar.collapsed .nav-item-text { display: none; }
27+
.sidebar.collapsed ~ .main-wrapper { margin-left: 56px !important; }
28+
29+
/* Active state — managed by StxLink + router via data-stx-active-class="active" */
30+
#sidebar .nav-item.active { background: #6366f1 !important; color: white !important; }
31+
#sidebar .nav-item.active svg { opacity: 1; }
32+
</style>
33+
</head>
34+
<body class="bg-[#0a0a0f] text-zinc-50 leading-relaxed min-h-screen">
35+
@include('sidebar')
36+
37+
<div class="main-wrapper transition-all duration-200 ease-in-out min-h-screen" style="margin-left: 240px;">
38+
@stack('styles')
39+
<main>
40+
@yield('content')
41+
</main>
42+
</div>
43+
44+
@stxRouter('main')
45+
</body>
46+
</html>

0 commit comments

Comments
 (0)