@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.
- package/README.md +10 -0
- package/package.json +65 -0
- package/src/apps/monogram.ts +15 -0
- package/src/apps/registry.ts +238 -0
- package/src/apps/startup-seed.ts +95 -0
- package/src/bash/client.ts +21 -0
- package/src/bash/codes.ts +21 -0
- package/src/compile/allowlist.ts +89 -0
- package/src/compile/asset-path.ts +48 -0
- package/src/compile/autogen.ts +71 -0
- package/src/compile/build-vendor-cli.ts +8 -0
- package/src/compile/build-vendor.ts +160 -0
- package/src/compile/bundle-ui.ts +137 -0
- package/src/compile/codes.ts +22 -0
- package/src/compile/failure.ts +38 -0
- package/src/compile/imports.ts +43 -0
- package/src/compile/leave.ts +47 -0
- package/src/compile/load-backend.ts +95 -0
- package/src/compile/review.ts +342 -0
- package/src/compile/runner.ts +205 -0
- package/src/compile/sheet.ts +174 -0
- package/src/compile/wrapper.ts +297 -0
- package/src/credentials/codes.ts +26 -0
- package/src/credentials/create.ts +69 -0
- package/src/credentials/file.ts +113 -0
- package/src/credentials/provider.ts +66 -0
- package/src/events/app-events.ts +214 -0
- package/src/events/error-ring.ts +109 -0
- package/src/events/host-events.ts +39 -0
- package/src/events/view-bridge.ts +402 -0
- package/src/events/view-eval.ts +213 -0
- package/src/files/codes.ts +28 -0
- package/src/files/skip.ts +23 -0
- package/src/files/tools.ts +215 -0
- package/src/history/codes.ts +22 -0
- package/src/history/store.ts +287 -0
- package/src/host/activity.ts +110 -0
- package/src/host/author-mcp.ts +248 -0
- package/src/host/author-skill.ts +196 -0
- package/src/host/codes.ts +21 -0
- package/src/host/config.ts +239 -0
- package/src/host/heat.ts +35 -0
- package/src/host/layout.ts +81 -0
- package/src/host/log.ts +142 -0
- package/src/host/mcp-editor.ts +205 -0
- package/src/host/mcp-import.ts +141 -0
- package/src/host/mcp.ts +109 -0
- package/src/host/port.ts +83 -0
- package/src/host/session.ts +358 -0
- package/src/host/workbench.ts +38 -0
- package/src/http/app.ts +46 -0
- package/src/http/asset.ts +49 -0
- package/src/http/author.ts +157 -0
- package/src/http/bind.ts +217 -0
- package/src/http/client.ts +175 -0
- package/src/http/codes.ts +29 -0
- package/src/http/env.ts +3 -0
- package/src/http/guard.ts +20 -0
- package/src/http/iframe.ts +212 -0
- package/src/http/layout.ts +63 -0
- package/src/http/owner.ts +380 -0
- package/src/http/ports.ts +114 -0
- package/src/http/reply.ts +53 -0
- package/src/http/route-codes.ts +19 -0
- package/src/http/updates.ts +258 -0
- package/src/index.ts +201 -0
- package/src/install/codes.ts +21 -0
- package/src/install/install.ts +230 -0
- package/src/install/layout.ts +16 -0
- package/src/kernel/bind-brain.ts +117 -0
- package/src/kernel/call.ts +134 -0
- package/src/kernel/codes.ts +21 -0
- package/src/kernel/index.ts +13 -0
- package/src/kernel/model-policy.ts +83 -0
- package/src/kernel/model-stream.ts +86 -0
- package/src/kernel/present.ts +4 -0
- package/src/kernel/working-directory.ts +35 -0
- package/src/metrics/codes.ts +22 -0
- package/src/metrics/read.ts +48 -0
- package/src/owner/read.ts +85 -0
- package/src/pwsh/client.ts +47 -0
- package/src/pwsh/codes.ts +21 -0
- package/src/shell/command.ts +172 -0
- package/src/storage/codes.ts +33 -0
- package/src/storage/layout.ts +44 -0
- package/src/storage/open.ts +461 -0
- package/src/storage/schema.ts +176 -0
- package/src/storage/sql.ts +40 -0
- package/src/theme/codes.ts +20 -0
- package/src/theme/layout.ts +42 -0
- package/src/theme/paint.ts +108 -0
- package/src/theme/parse.ts +58 -0
- package/src/theme/pin.ts +197 -0
- package/src/theme/tokens.ts +30 -0
- package/src/tools/author.ts +970 -0
- package/src/tools/codes.ts +21 -0
- package/src/tools/diagnostics.ts +53 -0
- package/src/tools/http.ts +48 -0
- package/src/tools/mcp-list.ts +90 -0
- package/src/tools/schemas.ts +185 -0
- package/themes/theme-default.css +4 -0
- package/themes/theme-forest.css +4 -0
- package/themes/theme-hokage.css +4 -0
- package/themes/theme-matcha.css +4 -0
- package/themes/theme-slate.css +4 -0
- package/themes/theme-tokyo.css +4 -0
- package/themes/theme-yellow.css +4 -0
- 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
|
+
}
|