@mohou/host 1.0.20

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.
Files changed (108) hide show
  1. package/README.md +10 -0
  2. package/package.json +65 -0
  3. package/src/apps/monogram.ts +15 -0
  4. package/src/apps/registry.ts +238 -0
  5. package/src/apps/startup-seed.ts +95 -0
  6. package/src/bash/client.ts +21 -0
  7. package/src/bash/codes.ts +21 -0
  8. package/src/compile/allowlist.ts +89 -0
  9. package/src/compile/asset-path.ts +48 -0
  10. package/src/compile/autogen.ts +71 -0
  11. package/src/compile/build-vendor-cli.ts +8 -0
  12. package/src/compile/build-vendor.ts +160 -0
  13. package/src/compile/bundle-ui.ts +137 -0
  14. package/src/compile/codes.ts +22 -0
  15. package/src/compile/failure.ts +38 -0
  16. package/src/compile/imports.ts +43 -0
  17. package/src/compile/leave.ts +47 -0
  18. package/src/compile/load-backend.ts +95 -0
  19. package/src/compile/review.ts +342 -0
  20. package/src/compile/runner.ts +205 -0
  21. package/src/compile/sheet.ts +174 -0
  22. package/src/compile/wrapper.ts +297 -0
  23. package/src/credentials/codes.ts +26 -0
  24. package/src/credentials/create.ts +69 -0
  25. package/src/credentials/file.ts +113 -0
  26. package/src/credentials/provider.ts +66 -0
  27. package/src/events/app-events.ts +214 -0
  28. package/src/events/error-ring.ts +109 -0
  29. package/src/events/host-events.ts +39 -0
  30. package/src/events/view-bridge.ts +402 -0
  31. package/src/events/view-eval.ts +213 -0
  32. package/src/files/codes.ts +28 -0
  33. package/src/files/skip.ts +23 -0
  34. package/src/files/tools.ts +215 -0
  35. package/src/history/codes.ts +22 -0
  36. package/src/history/store.ts +287 -0
  37. package/src/host/activity.ts +110 -0
  38. package/src/host/author-mcp.ts +248 -0
  39. package/src/host/author-skill.ts +196 -0
  40. package/src/host/codes.ts +21 -0
  41. package/src/host/config.ts +239 -0
  42. package/src/host/heat.ts +35 -0
  43. package/src/host/layout.ts +81 -0
  44. package/src/host/log.ts +142 -0
  45. package/src/host/mcp-editor.ts +205 -0
  46. package/src/host/mcp-import.ts +141 -0
  47. package/src/host/mcp.ts +109 -0
  48. package/src/host/port.ts +83 -0
  49. package/src/host/session.ts +358 -0
  50. package/src/host/workbench.ts +38 -0
  51. package/src/http/app.ts +46 -0
  52. package/src/http/asset.ts +49 -0
  53. package/src/http/author.ts +157 -0
  54. package/src/http/bind.ts +217 -0
  55. package/src/http/client.ts +175 -0
  56. package/src/http/codes.ts +29 -0
  57. package/src/http/env.ts +3 -0
  58. package/src/http/guard.ts +20 -0
  59. package/src/http/iframe.ts +212 -0
  60. package/src/http/layout.ts +63 -0
  61. package/src/http/owner.ts +380 -0
  62. package/src/http/ports.ts +114 -0
  63. package/src/http/reply.ts +53 -0
  64. package/src/http/route-codes.ts +19 -0
  65. package/src/http/updates.ts +258 -0
  66. package/src/index.ts +201 -0
  67. package/src/install/codes.ts +21 -0
  68. package/src/install/install.ts +230 -0
  69. package/src/install/layout.ts +16 -0
  70. package/src/kernel/bind-brain.ts +117 -0
  71. package/src/kernel/call.ts +134 -0
  72. package/src/kernel/codes.ts +21 -0
  73. package/src/kernel/index.ts +13 -0
  74. package/src/kernel/model-policy.ts +83 -0
  75. package/src/kernel/model-stream.ts +86 -0
  76. package/src/kernel/present.ts +4 -0
  77. package/src/kernel/working-directory.ts +35 -0
  78. package/src/metrics/codes.ts +22 -0
  79. package/src/metrics/read.ts +48 -0
  80. package/src/owner/read.ts +85 -0
  81. package/src/pwsh/client.ts +47 -0
  82. package/src/pwsh/codes.ts +21 -0
  83. package/src/shell/command.ts +172 -0
  84. package/src/storage/codes.ts +33 -0
  85. package/src/storage/layout.ts +44 -0
  86. package/src/storage/open.ts +461 -0
  87. package/src/storage/schema.ts +176 -0
  88. package/src/storage/sql.ts +40 -0
  89. package/src/theme/codes.ts +20 -0
  90. package/src/theme/layout.ts +42 -0
  91. package/src/theme/paint.ts +108 -0
  92. package/src/theme/parse.ts +58 -0
  93. package/src/theme/pin.ts +197 -0
  94. package/src/theme/tokens.ts +30 -0
  95. package/src/tools/author.ts +970 -0
  96. package/src/tools/codes.ts +21 -0
  97. package/src/tools/diagnostics.ts +53 -0
  98. package/src/tools/http.ts +48 -0
  99. package/src/tools/mcp-list.ts +90 -0
  100. package/src/tools/schemas.ts +185 -0
  101. package/themes/theme-default.css +4 -0
  102. package/themes/theme-forest.css +4 -0
  103. package/themes/theme-hokage.css +4 -0
  104. package/themes/theme-matcha.css +4 -0
  105. package/themes/theme-slate.css +4 -0
  106. package/themes/theme-tokyo.css +4 -0
  107. package/themes/theme-yellow.css +4 -0
  108. package/themes/theme-zoro.css +4 -0
@@ -0,0 +1,342 @@
1
+ import { readdirSync, readFileSync, statSync } from 'node:fs'
2
+ import path from 'node:path'
3
+ import type * as ts from 'typescript'
4
+
5
+ import { appEntries, appTrees, type DefinitionCode } from '@mohou/contract'
6
+
7
+ import { snapshotSkip } from '../files/skip.ts'
8
+ import type { CompileCode } from './codes.ts'
9
+ import { importDecision, sideOf } from './imports.ts'
10
+
11
+ /** A finding that does not fail reload. Callers match `code`. */
12
+ export const reviewNoticeCodes = [
13
+ 'keyframe-duplicate',
14
+ 'keyframe-collision',
15
+ 'identifier-skipped',
16
+ ] as const
17
+
18
+ export type ReviewNoticeCode = (typeof reviewNoticeCodes)[number]
19
+
20
+ export interface ReviewNotice {
21
+ readonly code: ReviewNoticeCode
22
+ readonly message: string
23
+ readonly path?: string
24
+ readonly name?: string
25
+ }
26
+
27
+ export interface ReviewError {
28
+ readonly code: CompileCode | DefinitionCode
29
+ readonly message: string
30
+ }
31
+
32
+ export interface AppReview {
33
+ readonly errors: ReviewError[]
34
+ readonly notices: ReviewNotice[]
35
+ }
36
+
37
+ type Ts = typeof import('typescript')
38
+
39
+ interface ReviewOptions {
40
+ readonly reservedKeyframes?: readonly string[]
41
+ readonly loadParser?: () => Promise<Ts | undefined>
42
+ }
43
+
44
+ /**
45
+ * Keyframe collisions are notices. An unbound component, or a one-edit typo of a
46
+ * declared name, fails that side. Other free names are not a list this module owns.
47
+ * A missing parser is a notice and does not fail reload.
48
+ * @param appDir - absolute app directory
49
+ */
50
+ export async function reviewApp(appDir: string, options: ReviewOptions = {}): Promise<AppReview> {
51
+ const notices = keyframeNotices(appDir, new Set(options.reservedKeyframes ?? []))
52
+ const parser = await loadParser(options.loadParser)
53
+ if (parser === undefined) {
54
+ notices.push({
55
+ code: 'identifier-skipped',
56
+ message: 'identifier pass skipped: parser did not load',
57
+ })
58
+ return { errors: [], notices }
59
+ }
60
+ const errors: ReviewError[] = []
61
+ const events = { push: new Map<string, string>(), on: new Map<string, string>() }
62
+ for (const file of sourceFiles(appDir)) {
63
+ const rel = relative(appDir, file)
64
+ try {
65
+ reviewFile(parser, rel, readFileSync(file, 'utf8'), errors, events)
66
+ } catch (error) {
67
+ errors.push({
68
+ code: codeFor(rel),
69
+ message: error instanceof Error ? error.message : `could not parse ${rel}`,
70
+ })
71
+ }
72
+ }
73
+ for (const [name, pushPath] of events.push) {
74
+ const onPath = events.on.get(name)
75
+ if (onPath === undefined) continue
76
+ errors.push({
77
+ code: 'event-undeclared',
78
+ message: `event ${name} is used in ${pushPath} and ${onPath}; declare it once in ${appTrees.shared}/`,
79
+ })
80
+ }
81
+ return { errors, notices }
82
+ }
83
+
84
+ async function loadParser(injected: ReviewOptions['loadParser']): Promise<Ts | undefined> {
85
+ try {
86
+ if (injected !== undefined) return await injected()
87
+ return await import('typescript')
88
+ } catch {
89
+ return undefined
90
+ }
91
+ }
92
+
93
+ const sharedForbidden = new Set([
94
+ 'ctx',
95
+ 'React',
96
+ 'document',
97
+ 'window',
98
+ 'navigator',
99
+ 'localStorage',
100
+ 'sessionStorage',
101
+ 'process',
102
+ 'Buffer',
103
+ 'require',
104
+ ])
105
+
106
+ function reviewFile(
107
+ parser: Ts,
108
+ rel: string,
109
+ text: string,
110
+ errors: ReviewError[],
111
+ events: { push: Map<string, string>; on: Map<string, string> },
112
+ ): void {
113
+ const source = parser.createSourceFile(rel, text, parser.ScriptTarget.Latest, true, scriptKind(parser, rel))
114
+ const declared = declaredNames(parser, source)
115
+ const shared = rel.startsWith(`${appTrees.shared}/`)
116
+ const visit = (node: ts.Node): void => {
117
+ if (shared && isJsx(parser, node)) {
118
+ errors.push({ code: 'shared-invalid', message: `${rel} cannot contain JSX` })
119
+ }
120
+ if (shared && parser.isIdentifier(node) && sharedForbidden.has(node.text) && !isPropertyName(parser, node)) {
121
+ errors.push({ code: 'shared-invalid', message: `${rel} cannot use ${node.text}` })
122
+ }
123
+ const specifier = importedSpecifier(parser, node)
124
+ if (specifier !== undefined) {
125
+ const decision = importDecision(sideOf(rel), specifier, rel)
126
+ if (decision !== 'allow' && decision !== 'install' && decision !== 'builtin') errors.push(decision)
127
+ }
128
+ const unbound = unboundName(parser, node, declared)
129
+ if (unbound !== undefined) {
130
+ errors.push({ code: codeFor(rel), message: `${rel} uses ${unbound}, which is not declared` })
131
+ }
132
+ const pushed = pushLiteral(parser, node)
133
+ if (pushed !== undefined && sideOf(rel) === 'backend' && !events.push.has(pushed)) events.push.set(pushed, rel)
134
+ const listened = onLiteral(parser, node)
135
+ if (listened !== undefined && sideOf(rel) === 'ui' && !events.on.has(listened)) events.on.set(listened, rel)
136
+ parser.forEachChild(node, visit)
137
+ }
138
+ visit(source)
139
+ }
140
+
141
+ function pushLiteral(parser: Ts, node: ts.Node): string | undefined {
142
+ if (!parser.isCallExpression(node) || !parser.isPropertyAccessExpression(node.expression)) return undefined
143
+ if (node.expression.name.text !== 'push') return undefined
144
+ if (!parser.isIdentifier(node.expression.expression) || node.expression.expression.text !== 'ctx') return undefined
145
+ return stringArg(parser, node)
146
+ }
147
+
148
+ function onLiteral(parser: Ts, node: ts.Node): string | undefined {
149
+ if (!parser.isCallExpression(node) || !parser.isPropertyAccessExpression(node.expression)) return undefined
150
+ if (node.expression.name.text !== 'on') return undefined
151
+ const target = node.expression.expression
152
+ if (!parser.isCallExpression(target) || !parser.isIdentifier(target.expression) || target.expression.text !== 'useApp') {
153
+ return undefined
154
+ }
155
+ const name = stringArg(parser, node)
156
+ return name === undefined || name === '*' ? undefined : name
157
+ }
158
+
159
+ function stringArg(parser: Ts, node: ts.CallExpression): string | undefined {
160
+ const arg = node.arguments[0]
161
+ if (arg === undefined || !parser.isStringLiteral(arg) || arg.text.length === 0) return undefined
162
+ return arg.text
163
+ }
164
+
165
+ function declaredNames(parser: Ts, source: ts.SourceFile): Set<string> {
166
+ const names = new Set<string>()
167
+ const visit = (node: ts.Node): void => {
168
+ if (parser.isImportDeclaration(node) && node.importClause?.phaseModifier !== parser.SyntaxKind.TypeKeyword) {
169
+ addImport(parser, node.importClause, names)
170
+ }
171
+ if ((parser.isFunctionDeclaration(node) || parser.isClassDeclaration(node)) && node.name !== undefined) {
172
+ names.add(node.name.text)
173
+ }
174
+ if (parser.isVariableDeclaration(node) && parser.isIdentifier(node.name)) names.add(node.name.text)
175
+ if (parser.isParameter(node) && parser.isIdentifier(node.name)) names.add(node.name.text)
176
+ if (parser.isBindingElement(node) && parser.isIdentifier(node.name)) names.add(node.name.text)
177
+ if (parser.isCatchClause(node) && node.variableDeclaration !== undefined && parser.isIdentifier(node.variableDeclaration.name)) {
178
+ names.add(node.variableDeclaration.name.text)
179
+ }
180
+ parser.forEachChild(node, visit)
181
+ }
182
+ visit(source)
183
+ return names
184
+ }
185
+
186
+ function addImport(parser: Ts, clause: ts.ImportClause | undefined, names: Set<string>): void {
187
+ if (clause === undefined) return
188
+ if (clause.name !== undefined) names.add(clause.name.text)
189
+ const bindings = clause.namedBindings
190
+ if (bindings === undefined) return
191
+ if (parser.isNamespaceImport(bindings)) {
192
+ names.add(bindings.name.text)
193
+ return
194
+ }
195
+ for (const specifier of bindings.elements) {
196
+ if (!specifier.isTypeOnly) names.add(specifier.name.text)
197
+ }
198
+ }
199
+
200
+ function isJsx(parser: Ts, node: ts.Node): boolean {
201
+ return parser.isJsxElement(node)
202
+ || parser.isJsxSelfClosingElement(node)
203
+ || parser.isJsxFragment(node)
204
+ }
205
+
206
+ function isPropertyName(parser: Ts, node: ts.Node): boolean {
207
+ const parent = node.parent
208
+ return (parser.isPropertyAccessExpression(parent) || parser.isPropertyAssignment(parent)) && parent.name === node
209
+ }
210
+
211
+ function importedSpecifier(parser: Ts, node: ts.Node): string | undefined {
212
+ if (parser.isImportDeclaration(node) || parser.isExportDeclaration(node)) {
213
+ const specifier = node.moduleSpecifier
214
+ if (specifier !== undefined && parser.isStringLiteral(specifier)) return specifier.text
215
+ }
216
+ if (parser.isCallExpression(node) && node.expression.kind === parser.SyntaxKind.ImportKeyword) {
217
+ const arg = node.arguments[0]
218
+ if (arg !== undefined && parser.isStringLiteral(arg)) return arg.text
219
+ }
220
+ return undefined
221
+ }
222
+
223
+ function unboundName(parser: Ts, node: ts.Node, declared: Set<string>): string | undefined {
224
+ if (parser.isJsxOpeningElement(node) || parser.isJsxSelfClosingElement(node)) {
225
+ const name = jsxRoot(parser, node.tagName)
226
+ if (name !== undefined && !declared.has(name)) return name
227
+ }
228
+ if (!parser.isCallExpression(node) || !parser.isIdentifier(node.expression)) return undefined
229
+ const name = node.expression.text
230
+ if (declared.has(name) || near(name, declared) === undefined) return undefined
231
+ return name
232
+ }
233
+
234
+ function jsxRoot(parser: Ts, tag: ts.JsxTagNameExpression): string | undefined {
235
+ if (parser.isIdentifier(tag) && /^[A-Z]/.test(tag.text)) return tag.text
236
+ if (parser.isPropertyAccessExpression(tag) && parser.isIdentifier(tag.expression)) return tag.expression.text
237
+ return undefined
238
+ }
239
+
240
+ function near(name: string, declared: Set<string>): string | undefined {
241
+ for (const known of declared) {
242
+ if (oneEdit(name, known)) return known
243
+ }
244
+ return undefined
245
+ }
246
+
247
+ function oneEdit(left: string, right: string): boolean {
248
+ if (left === right || Math.abs(left.length - right.length) > 1) return false
249
+ let edits = 0
250
+ let i = 0
251
+ let j = 0
252
+ while (i < left.length && j < right.length) {
253
+ if (left[i] === right[j]) {
254
+ i += 1
255
+ j += 1
256
+ continue
257
+ }
258
+ edits += 1
259
+ if (edits > 1) return false
260
+ if (left.length > right.length) i += 1
261
+ else if (right.length > left.length) j += 1
262
+ else {
263
+ i += 1
264
+ j += 1
265
+ }
266
+ }
267
+ return edits + (left.length - i) + (right.length - j) === 1
268
+ }
269
+
270
+ function keyframeNotices(appDir: string, reserved: Set<string>): ReviewNotice[] {
271
+ const seen = new Map<string, string>()
272
+ const notices: ReviewNotice[] = []
273
+ for (const file of textFiles(appDir)) {
274
+ const rel = relative(appDir, file)
275
+ for (const match of readFileSync(file, 'utf8').matchAll(/@keyframes\s+([A-Za-z_][\w-]*)/g)) {
276
+ const name = match[1] ?? ''
277
+ if (name.length === 0) continue
278
+ const first = seen.get(name)
279
+ if (first !== undefined) {
280
+ notices.push({
281
+ code: 'keyframe-duplicate',
282
+ message: `@keyframes ${name} is declared in ${first} and ${rel}`,
283
+ path: rel,
284
+ name,
285
+ })
286
+ } else {
287
+ seen.set(name, rel)
288
+ }
289
+ if (reserved.has(name)) {
290
+ notices.push({
291
+ code: 'keyframe-collision',
292
+ message: `@keyframes ${name} is reserved`,
293
+ path: rel,
294
+ name,
295
+ })
296
+ }
297
+ }
298
+ }
299
+ return notices
300
+ }
301
+
302
+ function codeFor(rel: string): CompileCode {
303
+ if (rel === appEntries.ui || rel.startsWith(`${appTrees.ui}/`)) return 'ui-invalid'
304
+ if (rel.startsWith(`${appTrees.shared}/`)) return 'shared-invalid'
305
+ return 'backend-invalid'
306
+ }
307
+
308
+ function scriptKind(parser: Ts, rel: string): ts.ScriptKind {
309
+ if (rel.endsWith('.tsx')) return parser.ScriptKind.TSX
310
+ if (rel.endsWith('.jsx')) return parser.ScriptKind.JSX
311
+ if (rel.endsWith('.js')) return parser.ScriptKind.JS
312
+ return parser.ScriptKind.TS
313
+ }
314
+
315
+ function sourceFiles(appDir: string): string[] {
316
+ return textFiles(appDir).filter((file) => {
317
+ const rel = relative(appDir, file)
318
+ return rel === appEntries.ui
319
+ || rel === appEntries.backend
320
+ || rel.startsWith(`${appTrees.ui}/`)
321
+ || rel.startsWith(`${appTrees.api}/`)
322
+ || rel.startsWith(`${appTrees.shared}/`)
323
+ }).filter(file => /\.(ts|tsx|js|jsx)$/.test(file))
324
+ }
325
+
326
+ function textFiles(appDir: string): string[] {
327
+ const found: string[] = []
328
+ const walk = (dir: string): void => {
329
+ for (const name of readdirSync(dir)) {
330
+ if (snapshotSkip.has(name)) continue
331
+ const full = path.join(dir, name)
332
+ if (statSync(full).isDirectory()) walk(full)
333
+ else if (/\.(css|ts|tsx|js|jsx)$/.test(name)) found.push(full)
334
+ }
335
+ }
336
+ walk(appDir)
337
+ return found
338
+ }
339
+
340
+ function relative(appDir: string, file: string): string {
341
+ return path.relative(appDir, file).split(path.sep).join('/')
342
+ }
@@ -0,0 +1,205 @@
1
+ import { renderViewBridge } from '../events/view-bridge.ts'
2
+ import { appResource, httpLayout } from '../http/layout.ts'
3
+ import { diagnosticUrl } from '../tools/diagnostics.ts'
4
+ import { platformImportMap } from './allowlist.ts'
5
+ import { leaveGuardSource } from './leave.ts'
6
+ import { hostWrapperBinding, renderHostWrapper } from './wrapper.ts'
7
+
8
+ /** Thin bar on WKWebView and WebView2. `scrollbar-width` covers the rest. */
9
+ const thinScrollbarCss = 'html{scrollbar-width:thin;scrollbar-color:color-mix(in srgb,CanvasText 32%,transparent) transparent}*{scrollbar-width:thin;scrollbar-color:color-mix(in srgb,CanvasText 32%,transparent) transparent}::-webkit-scrollbar{-webkit-appearance:none;width:8px;height:8px}::-webkit-scrollbar-track,::-webkit-scrollbar-corner{background:transparent}::-webkit-scrollbar-thumb{background:color-mix(in srgb,CanvasText 32%,transparent);border:2px solid transparent;border-radius:999px;background-clip:padding-box}'
10
+
11
+ /**
12
+ * The first document for one app. The iframe runner route serves it.
13
+ * Boot art is cleared before the component mounts. The host injects the error boundary.
14
+ * The UI bundle is a later request. This document does not wait for it.
15
+ * @param input - app id, entry URL, and first-paint CSS
16
+ */
17
+ export function renderRunnerDocument(input: {
18
+ readonly appId: string
19
+ readonly entry: string
20
+ readonly style: string
21
+ readonly appearance: 'system' | 'light' | 'dark'
22
+ /** Host chrome locale. Kit uses `en` | `zh`. */
23
+ readonly locale?: string
24
+ }): string {
25
+ const errors = diagnosticUrl(input.appId, 'errors')
26
+ const alive = diagnosticUrl(input.appId, 'alive')
27
+ return `<!doctype html>
28
+ <html${appearanceAttribute(input.appearance)}>
29
+ <head>
30
+ <meta charset="utf-8">
31
+ <link rel="stylesheet" href="${appResource(input.appId, httpLayout.ui, httpLayout.sheet)}">
32
+ <style id="mma-first-paint">${escapeStyle(input.style)}</style>
33
+ <style>html,body,#root{height:100%;margin:0}#root>div{height:100%;min-height:0;display:flex;flex-direction:column}${thinScrollbarCss}</style>
34
+ <script type="importmap">
35
+ ${JSON.stringify({ imports: platformImportMap() })}
36
+ </script>
37
+ ${appearanceScript(input.appearance)}
38
+ <script>
39
+ ${leaveGuardSource()}
40
+ </script>
41
+ </head>
42
+ <body>
43
+ <div id="boot"></div>
44
+ <div id="root"></div>
45
+ <script>
46
+ ${renderViewBridge({ appId: input.appId, evalUrl: diagnosticUrl(input.appId, 'viewEval') })}
47
+ ${renderHostWrapper(input.appId)}
48
+ </script>
49
+ <script type="module">
50
+ ${reportedMessageSource()}
51
+ const boot = document.getElementById('boot')
52
+ if (boot) boot.remove()
53
+ await fetch(${JSON.stringify(alive)}, { method: 'POST', body: '{}' })
54
+ ${uncaughtReporterSource(errors)}
55
+ ${asyncReporterSource(errors)}
56
+ ${moduleErrorPaintSource(kitLocale(input.locale))}
57
+ try {
58
+ const module = await import(${JSON.stringify(input.entry)})
59
+ const component = module.default
60
+ if (typeof component !== 'function') {
61
+ const message = 'default export is not a component'
62
+ void fetch(${JSON.stringify(errors)}, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ kind: 'module', message }) })
63
+ paintModuleError(${JSON.stringify(input.appId)}, message)
64
+ } else {
65
+ const React = await import('react')
66
+ const ui = await import('@mohou/ui')
67
+ const frame = window[${JSON.stringify(hostWrapperBinding.host)}]
68
+ function HostFrame(props) {
69
+ return frame.runInside(() => props.component({}))
70
+ }
71
+ const root = React.createElement(
72
+ ui.AppRuntime,
73
+ { appId: ${JSON.stringify(input.appId)} },
74
+ React.createElement(
75
+ ui.UiProvider,
76
+ { locale: ${JSON.stringify(kitLocale(input.locale))} },
77
+ React.createElement(ui.AppErrorBoundary, null, React.createElement(HostFrame, { component })),
78
+ ),
79
+ )
80
+ const target = document.getElementById('root')
81
+ if (target) target.replaceChildren(document.createTextNode(''))
82
+ const host = document.createElement('div')
83
+ target?.append(host)
84
+ const client = React.createRoot?.(host)
85
+ if (client) client.render(root)
86
+ }
87
+ } catch (error) {
88
+ const message = reportedMessage(error)
89
+ const text = message.length > 0 ? message : 'UI module failed to load'
90
+ if (message.length > 0) {
91
+ void fetch(${JSON.stringify(errors)}, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ kind: 'module', message }) })
92
+ }
93
+ paintModuleError(${JSON.stringify(input.appId)}, text)
94
+ }
95
+ </script>
96
+ </body>
97
+ </html>`
98
+ }
99
+
100
+ /** Message already on an error. Empty and non-errors are not given a stand-in. */
101
+ export function reportedMessageSource(): string {
102
+ return `function reportedMessage(error) {
103
+ if (error instanceof Error) return error.message
104
+ return typeof error === 'string' ? error : ''
105
+ }`
106
+ }
107
+
108
+ /**
109
+ * Vanilla fallback when the UI module never mounts React.
110
+ * Does not import the kit — module failure may be the kit itself.
111
+ */
112
+ export function moduleErrorPaintSource(locale: 'en' | 'zh'): string {
113
+ const copy = locale === 'zh'
114
+ ? {
115
+ title: '小程序未能加载',
116
+ hint: '入口模块没有起来,通常是依赖导出缺失或需要重新编译。',
117
+ reload: '重新加载',
118
+ }
119
+ : {
120
+ title: 'This mini-app failed to load',
121
+ hint: 'The UI bundle did not load — a missing export or a compile is the usual cause.',
122
+ reload: 'Reload',
123
+ }
124
+ return `function paintModuleError(appId, message) {
125
+ const target = document.getElementById('root')
126
+ if (!target) return
127
+ target.replaceChildren()
128
+ const card = document.createElement('div')
129
+ card.setAttribute('role', 'alert')
130
+ card.dataset.moduleError = '1'
131
+ card.style.cssText = 'box-sizing:border-box;margin:24px;padding:16px 18px;border-radius:12px;border:1px solid var(--destructive,#dc2626);background:var(--card,#fff);color:var(--foreground,#111);font:13px/1.6 var(--font-sans,ui-sans-serif,system-ui,sans-serif);max-width:720px'
132
+ const head = document.createElement('div')
133
+ head.style.cssText = 'display:flex;align-items:center;gap:8px;margin-bottom:6px'
134
+ const dot = document.createElement('span')
135
+ dot.style.cssText = 'width:8px;height:8px;border-radius:50%;background:var(--destructive,#dc2626);flex:0 0 auto'
136
+ const title = document.createElement('strong')
137
+ title.style.fontSize = '14px'
138
+ title.textContent = ${JSON.stringify(copy.title)}
139
+ const id = document.createElement('code')
140
+ id.style.cssText = 'margin-left:auto;padding:1px 6px;border-radius:6px;background:var(--muted,#f3f4f6);color:var(--muted-foreground,#6b7280);font-size:11px'
141
+ id.textContent = appId
142
+ head.append(dot, title, id)
143
+ const hint = document.createElement('p')
144
+ hint.style.cssText = 'margin:0 0 8px;color:var(--muted-foreground,#6b7280)'
145
+ hint.textContent = ${JSON.stringify(copy.hint)}
146
+ const pre = document.createElement('pre')
147
+ pre.style.cssText = 'margin:0;padding:10px 12px;border-radius:8px;background:var(--muted,#f3f4f6);white-space:pre-wrap;word-break:break-word;font-family:var(--font-mono,ui-monospace,SFMono-Regular,monospace);font-size:12px'
148
+ pre.textContent = message
149
+ const row = document.createElement('div')
150
+ row.style.cssText = 'display:flex;gap:8px;align-items:center;margin-top:12px'
151
+ const btn = document.createElement('button')
152
+ btn.type = 'button'
153
+ btn.textContent = ${JSON.stringify(copy.reload)}
154
+ btn.style.cssText = 'padding:5px 12px;border-radius:8px;border:1px solid var(--border,#e5e7eb);background:var(--primary,#2563eb);color:var(--primary-foreground,#fff);font:inherit;cursor:pointer'
155
+ btn.onclick = () => { location.reload() }
156
+ row.append(btn)
157
+ card.append(head, hint, pre, row)
158
+ target.append(card)
159
+ }`
160
+ }
161
+
162
+ /** Post an unhandled rejection only when it already has a message. */
163
+ export function asyncReporterSource(errorsUrl: string): string {
164
+ return `window.addEventListener('unhandledrejection', (event) => {
165
+ const reason = event.reason
166
+ const message = reason instanceof Error ? reason.message : typeof reason === 'string' ? reason : ''
167
+ if (message.length === 0) return
168
+ void fetch(${JSON.stringify(errorsUrl)}, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ kind: 'async', message }) })
169
+ })`
170
+ }
171
+
172
+ /** Drop a resource-load error. Those events have no message and are not script failures. */
173
+ export function uncaughtReporterSource(errorsUrl: string): string {
174
+ return `window.addEventListener('error', (event) => {
175
+ if (event.target && event.target !== window) return
176
+ if (typeof event.message !== 'string' || event.message.length === 0) return
177
+ void fetch(${JSON.stringify(errorsUrl)}, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ kind: 'uncaught', message: event.message }) })
178
+ })`
179
+ }
180
+
181
+ function escapeStyle(style: string): string {
182
+ return style.replaceAll('</', '<\\/')
183
+ }
184
+
185
+ /** Map host `en` | `zh-CN` onto kit `en` | `zh`. */
186
+ export function kitLocale(host: string | undefined): 'en' | 'zh' {
187
+ if (host === 'zh-CN' || host === 'zh') return 'zh'
188
+ return 'en'
189
+ }
190
+
191
+ function appearanceAttribute(appearance: 'system' | 'light' | 'dark'): string {
192
+ if (appearance === 'system') return ''
193
+ return ` data-mode="${appearance}"`
194
+ }
195
+
196
+ function appearanceScript(appearance: 'system' | 'light' | 'dark'): string {
197
+ if (appearance !== 'system') return ''
198
+ return `<script>
199
+ const applyMode = () => {
200
+ document.documentElement.dataset.mode = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
201
+ }
202
+ applyMode()
203
+ matchMedia('(prefers-color-scheme: dark)').addEventListener('change', applyMode)
204
+ </script>`
205
+ }