///|
pub struct WechatPage {
wxml : String
wxss : String
js : String
json : String
plan : @core.RenderPlan
render_budget : @core.RenderBudget
scene_assets : Array[WechatSceneAsset]
} derive(Debug, Eq)
///|
pub struct WechatSceneAsset {
id : String
kind : String
src : String
source_path : String
remote : Bool
estimated_bytes : Int
} derive(Debug, Eq)
///|
pub struct WechatEventPatch {
message : String
patches : Array[@core.Patch]
} derive(Debug, Eq)
///|
pub struct WechatRuntime {
initial_data_json : String
event_patches : Array[WechatEventPatch]
page_methods_js : String
} derive(Debug, Eq)
///|
pub struct WechatProjectPage {
name : String
route : String
page : WechatPage
runtime : WechatRuntime
} derive(Debug, Eq)
///|
pub struct WechatFile {
path : String
content : String
} derive(Debug, Eq)
///|
pub struct WechatManifestFile {
path : String
kind : String
route : String
bytes : Int
} derive(Debug, Eq)
///|
pub struct WechatManifestSceneAsset {
route : String
id : String
kind : String
src : String
remote : Bool
estimated_bytes : Int
} derive(Debug, Eq)
///|
pub struct WechatManifestPage {
name : String
route : String
node_count : Int
depth : Int
windowed_list_count : Int
list_total_count : Int
list_visible_count : Int
unwindowed_list_child_count : Int
unkeyed_list_child_count : Int
duplicate_list_key_count : Int
component_count : Int
event_count : Int
backend_event_count : Int
backend_stream_count : Int
backend_review_count : Int
backend_cancel_count : Int
backend_retry_count : Int
scene_count : Int
scene_marker_count : Int
scene_visible_marker_count : Int
scene_region_count : Int
scene_visible_region_count : Int
scene_asset_count : Int
scene_assets : Array[WechatManifestSceneAsset]
scene_package_bytes : Int
scene_remote_asset_count : Int
degraded_scene_count : Int
missing_event_patch_count : Int
orphan_event_patch_count : Int
initial_data_bytes : Int
event_patch_bytes : Int
event_patch_operation_count : Int
first_screen_bytes : Int
update_payload_bytes : Int
update_operation_count : Int
file_bytes : Int
render_budget : @core.RenderBudget
render_diagnostics : Array[String]
diagnostics : Array[String]
diagnostic_count : Int
status : String
} derive(Debug, Eq)
///|
pub struct WechatProjectManifest {
name : String
entry_route : String
page_count : Int
app_file_count : Int
app_total_bytes : Int
scene_asset_count : Int
scene_remote_asset_count : Int
scene_package_bytes : Int
diagnostic_route_count : Int
diagnostic_count : Int
pages : Array[WechatManifestPage]
files : Array[WechatManifestFile]
scene_assets : Array[WechatManifestSceneAsset]
summary : String
} derive(Debug, Eq)
///|
pub struct WechatProject {
name : String
route : String
page : WechatPage
pages : Array[WechatProjectPage]
page_count : Int
manifest : WechatProjectManifest
files : Array[WechatFile]
total_bytes : Int
report : WechatBuildReport
summary : String
} derive(Debug, Eq)
///|
pub fn project_with_report(
project : WechatProject,
report : WechatBuildReport,
) -> WechatProject {
{ ..project, report, }
}
///|
pub fn page_with_wxss(page : WechatPage, wxss : String) -> WechatPage {
{ ..page, wxss, }
}
///|
pub fn project_page_with_wxss(
page : WechatProjectPage,
wxss : String,
) -> WechatProjectPage {
{ ..page, page: page_with_wxss(page.page, wxss) }
}
///|
pub fn project_page_with_runtime(
page : WechatProjectPage,
runtime : WechatRuntime,
) -> WechatProjectPage {
{ ..page, runtime, }
}
///|
pub fn generate_page(name : String, root : @core.Node) -> WechatPage {
generate_page_with_runtime(name, root, default_runtime())
}
///|
pub fn generate_page_with_runtime(
name : String,
root : @core.Node,
runtime : WechatRuntime,
) -> WechatPage {
generate_page_with_runtime_and_budget(
name,
root,
runtime,
@core.default_render_budget(),
)
}
///|
pub fn generate_page_with_runtime_and_budget(
name : String,
root : @core.Node,
runtime : WechatRuntime,
render_budget : @core.RenderBudget,
) -> WechatPage {
let plan = @core.plan_with_budget(root, Wechat, render_budget)
{
wxml: render_node(root, @core.adapter(Wechat)),
wxss: default_wxss(),
js: page_js(name, plan, runtime),
json: "{ \"navigationBarTitleText\": \"\{escape_json(name)}\" }",
plan,
render_budget,
scene_assets: collect_scene_assets(root),
}
}
///|
fn generate_project_page_with_runtime(
name : String,
route : String,
root : @core.Node,
runtime : WechatRuntime,
render_budget : @core.RenderBudget,
) -> WechatPage {
let plan = @core.plan_with_budget(root, Wechat, render_budget)
{
wxml: render_node(root, @core.adapter(Wechat)),
wxss: "",
js: project_page_js(name, route, plan, runtime),
json: "{ \"navigationBarTitleText\": \"\{escape_json(name)}\" }",
plan,
render_budget,
scene_assets: collect_scene_assets(root),
}
}
///|
pub fn generate_project(
name~ : String,
route? : String = "pages/index/index",
root~ : @core.Node,
) -> WechatProject {
generate_project_with_runtime_and_budget(
name~,
route~,
root~,
runtime=default_runtime(),
render_budget=@core.default_render_budget(),
)
}
///|
pub fn generate_project_with_runtime(
name~ : String,
route? : String = "pages/index/index",
root~ : @core.Node,
runtime~ : WechatRuntime,
) -> WechatProject {
generate_project_with_runtime_and_budget(
name~,
route~,
root~,
runtime~,
render_budget=@core.default_render_budget(),
)
}
///|
pub fn generate_project_with_runtime_and_budget(
name~ : String,
route? : String = "pages/index/index",
root~ : @core.Node,
runtime~ : WechatRuntime,
render_budget~ : @core.RenderBudget,
) -> WechatProject {
generate_project_from_pages(name~, pages=[
project_page(name~, route~, root~, runtime~, render_budget~),
])
}
///|
pub fn generate_project_with_runtime_and_backend(
name~ : String,
route? : String = "pages/index/index",
root~ : @core.Node,
runtime~ : WechatRuntime,
backend_contract~ : @effects.BackendContract,
) -> WechatProject {
generate_project_with_runtime_and_budget_and_backend(
name~,
route~,
root~,
runtime~,
render_budget=@core.default_render_budget(),
backend_contract~,
)
}
///|
pub fn generate_project_with_runtime_and_budget_and_backend(
name~ : String,
route? : String = "pages/index/index",
root~ : @core.Node,
runtime~ : WechatRuntime,
render_budget~ : @core.RenderBudget,
backend_contract~ : @effects.BackendContract,
) -> WechatProject {
generate_project_from_pages_with_backend(
name~,
pages=[project_page(name~, route~, root~, runtime~, render_budget~)],
backend_contract~,
)
}
///|
pub fn project_page(
name~ : String,
route~ : String,
root~ : @core.Node,
runtime? : WechatRuntime = default_runtime(),
render_budget? : @core.RenderBudget = @core.default_render_budget(),
) -> WechatProjectPage {
{
name,
route,
page: generate_project_page_with_runtime(
name, route, root, runtime, render_budget,
),
runtime,
}
}
///|
pub fn generate_project_from_pages(
name~ : String,
pages~ : Array[WechatProjectPage],
) -> WechatProject {
generate_project_from_pages_internal(name~, pages~, backend_contract=None)
}
///|
pub fn generate_project_from_pages_with_backend(
name~ : String,
pages~ : Array[WechatProjectPage],
backend_contract~ : @effects.BackendContract,
) -> WechatProject {
generate_project_from_pages_internal(
name~,
pages~,
backend_contract=Some(backend_contract),
)
}
///|
fn generate_project_from_pages_internal(
name~ : String,
pages~ : Array[WechatProjectPage],
backend_contract~ : @effects.BackendContract?,
) -> WechatProject {
if pages.length() == 0 {
panic()
}
let generated_pages = project_pages_for_backend(pages, backend_contract)
let entry = generated_pages[0]
let shared_wxss = repeated_project_wxss(generated_pages)
let files : Array[WechatFile] = [
wechat_file("app.json", app_json(generated_pages)),
wechat_file("project.config.json", project_config_json(name)),
wechat_file("sitemap.json", sitemap_json()),
wechat_file("bunnia.runtime.js", runtime_js()),
wechat_file("app.wxss", project_app_wxss(shared_wxss)),
]
match backend_contract {
Some(contract) =>
files.push(
wechat_file(
"bunnia.backend.js",
generate_request_adapter(
backend_contract_for_pages(contract, generated_pages),
),
),
)
None => ()
}
for item in generated_pages {
files.push(wechat_file("\{item.route}.wxml", item.page.wxml))
let compacted_wxss = compact_wxss(item.page.wxss)
let unique_wxss = project_unique_wxss(compacted_wxss, shared_wxss)
if unique_wxss != "" {
files.push(wechat_file("\{item.route}.wxss", unique_wxss))
}
files.push(
wechat_file(
page_payload_file_path(item.route),
page_payload_js(item.runtime, item.page.plan),
),
)
files.push(wechat_file("\{item.route}.js", item.page.js))
files.push(wechat_file("\{item.route}.json", item.page.json))
}
append_packaged_scene_asset_files(files, generated_pages)
let manifest = project_manifest(
name,
entry.route,
generated_pages,
files,
backend_contract,
)
files.push(wechat_file("bunnia.manifest.json", manifest_json(manifest)))
let total = total_file_bytes(files, generated_pages)
let report = report_project_pages(
files,
generated_pages,
default_build_budget(),
)
{
name,
route: entry.route,
page: entry.page,
pages: generated_pages,
page_count: generated_pages.length(),
manifest,
files,
total_bytes: total,
report,
summary: "Bunnia WeChat project: pages=\{generated_pages.length()} files=\{files.length()} bytes=\{total} route=\{entry.route}",
}
}
///|
fn project_app_wxss(shared_wxss : Array[String]) -> String {
let out = StringBuilder::new()
out.write_string(compact_wxss(default_wxss()))
for wxss in shared_wxss {
out.write_string(wxss)
}
out.to_string()
}
///|
fn repeated_project_wxss(pages : Array[WechatProjectPage]) -> Array[String] {
let repeated : Array[String] = []
let shared_prefix = longest_shared_project_wxss_prefix(pages)
if shared_prefix != "" {
repeated.push(shared_prefix)
}
for item in pages {
let wxss = compact_wxss(item.page.wxss)
if wxss != "" &&
!repeated.contains(wxss) &&
project_wxss_occurrence_count(pages, wxss) > 1 {
repeated.push(wxss)
}
}
repeated
}
///|
fn longest_shared_project_wxss_prefix(
pages : Array[WechatProjectPage],
) -> String {
let mut best = ""
let mut best_savings = 0
for i in 0.. 1 && savings > best_savings {
best = prefix
best_savings = savings
}
}
}
best
}
///|
fn project_wxss_prefix_occurrence_count(
pages : Array[WechatProjectPage],
prefix : String,
) -> Int {
if prefix == "" {
return 0
}
let mut count = 0
for item in pages {
if compact_wxss(item.page.wxss).has_prefix(prefix) {
count += 1
}
}
count
}
///|
fn shared_css_rule_prefix(left : String, right : String) -> String {
let limit = if left.length() < right.length() {
left.length()
} else {
right.length()
}
let mut rule_end = 0
for i in 0.. String {
for chunk in shared {
if wxss == chunk {
return ""
}
}
let mut shared_prefix = ""
for chunk in shared {
if wxss.has_prefix(chunk) && chunk.length() > shared_prefix.length() {
shared_prefix = chunk
}
}
if shared_prefix == "" {
wxss
} else {
wxss.sub(start=shared_prefix.length(), end=wxss.length()).to_owned()
}
}
///|
fn project_wxss_occurrence_count(
pages : Array[WechatProjectPage],
wxss : String,
) -> Int {
let mut count = 0
for item in pages {
if compact_wxss(item.page.wxss) == wxss {
count += 1
}
}
count
}
///|
fn project_pages_for_backend(
pages : Array[WechatProjectPage],
backend_contract : @effects.BackendContract?,
) -> Array[WechatProjectPage] {
let output : Array[WechatProjectPage] = []
for item in pages {
match backend_contract {
Some(contract) =>
output.push(project_page_with_backend_js(item, contract))
None => output.push(item)
}
}
output
}
///|
fn backend_contract_for_pages(
contract : @effects.BackendContract,
pages : Array[WechatProjectPage],
) -> @effects.BackendContract {
let endpoints : Array[@effects.BackendEndpoint] = []
for endpoint in contract.endpoints {
for page in pages {
if page.page.plan.event_messages.contains(endpoint.id) {
endpoints.push(endpoint)
break
}
}
}
@effects.backend_contract(
id=contract.id,
base_url_key=contract.base_url_key,
session_token_key=contract.session_token_key,
session_header_key=contract.session_header_key,
endpoints~,
)
}
///|
fn project_page_with_backend_js(
item : WechatProjectPage,
contract : @effects.BackendContract,
) -> WechatProjectPage {
let js = if backend_has_reachable_event(contract, item.page.plan) {
project_page_js_with_backend(
item.name,
item.route,
item.page.plan,
item.runtime,
contract,
)
} else {
project_page_js(item.name, item.route, item.page.plan, item.runtime)
}
{
name: item.name,
route: item.route,
page: {
wxml: item.page.wxml,
wxss: item.page.wxss,
js,
json: item.page.json,
plan: item.page.plan,
render_budget: item.page.render_budget,
scene_assets: item.page.scene_assets,
},
runtime: item.runtime,
}
}
///|
fn backend_has_reachable_event(
contract : @effects.BackendContract,
plan : @core.RenderPlan,
) -> Bool {
for endpoint in contract.endpoints {
if plan.event_messages.contains(endpoint.id) {
return true
}
}
false
}
///|
pub fn default_runtime() -> WechatRuntime {
{ initial_data_json: "{}", event_patches: [], page_methods_js: "" }
}
///|
pub fn runtime(
initial_data_json? : String = "{}",
event_patches? : Array[WechatEventPatch] = [],
page_methods_js? : String = "",
) -> WechatRuntime {
{ initial_data_json, event_patches, page_methods_js }
}
///|
pub fn event_patch(
message : String,
patches : Array[@core.Patch],
) -> WechatEventPatch {
{ message, patches }
}
///|
pub fn generate_set_data_call(patches : Array[@core.Patch]) -> String {
"__bunniaApplyPatches(this, \{patches_to_operations(patches)})"
}
///|
pub fn generate_runtime_js() -> String {
runtime_js()
}
///|
pub fn generate_request_adapter(contract : @effects.BackendContract) -> String {
"const __bunniaBackend = \{backend_contract_object(contract)}\n" +
"const __bk='runId reviewId decision buildingId bookId agentId targetUserId noticeId noticeKind targetRef discoveryAction discoveryId discoveryKind query filter kind channel status visibility sensitivity title workspaceId buildingListingId agentListingId memberPrincipalIds limit cursor placeableOnly includeAlerts outcomeSubmission summary guidance instruction parentRunId targetRunId listingId listingKind actionId message channelId messageId mentions audiencePrincipalIds notificationId subscriptionId topicKind topicId enabled targetState targetPrincipalId grantId permission expiresAt expiresAtMs requestId expectedVersion requestMessage decisionSummary reviewSummary claimCeiling maxCostMicroUsd maxRuntimeMs maxTokens maxArtifacts webSearch'.split(' ')\n" +
"const __bunniaSessionStorageKey='__bunnia_session__'+(__bunniaBackend.s||'default')\n" +
"\n" +
"function __bunniaRequest(page, endpointId, payload, settled) {\n" +
" const endpoint = __bunniaBackend.e[endpointId]\n" +
" if (!endpoint) throw new Error('Unknown Bunnia endpoint: ' + endpointId)\n" +
" const baseUrl = page.data[__bunniaBackend.b] || ''\n" +
" const loading = {}\n" +
" loading['backend.' + endpointId + '.state'] = 'loading'\n" +
" loading['backend.' + endpointId + '.error'] = ''\n" +
" loading['requestState.state'] = 'loading'\n" +
" loading['requestState.error'] = ''\n" +
" loading['requestState.statusCode'] = 0\n" +
" __bs(loading, endpointId, 'loading', '')\n" +
" const requestPayload = payload === undefined && endpoint.p ? page.data[endpoint.p] : (payload || {})\n" +
" loading['backend.' + endpointId + '.request'] = requestPayload\n" +
" loading['backend.' + endpointId + '.replayKey'] = endpointId + ':' + Date.now()\n" +
" page.setData(loading)\n" +
" return wx.request({\n" +
" url: baseUrl + endpoint.u,\n" +
" method: endpoint.m,\n" +
" data: requestPayload,\n" +
" header: __bunniaRequestHeaders(page, endpointId),\n" +
" timeout: endpoint.t,\n" +
" success(res) {\n" +
" const update = {}\n" +
" update['requestState.statusCode'] = Number(res.statusCode || 0)\n" +
" if (res.statusCode && (res.statusCode < 200 || res.statusCode >= 300)) {\n" +
" update['backend.' + endpointId + '.state'] = 'failed'\n" +
" update['backend.' + endpointId + '.error'] = 'HTTP ' + res.statusCode\n" +
" update['requestState.state'] = 'failed'\n" +
" update['requestState.error'] = 'Request failed'\n" +
" __bs(update, endpointId, 'failed', 'HTTP ' + res.statusCode)\n" +
" } else if (res.data === undefined || res.data === null) {\n" +
" update['backend.' + endpointId + '.state'] = 'malformed'\n" +
" update['backend.' + endpointId + '.error'] = 'empty response'\n" +
" update['requestState.state'] = 'failed'\n" +
" update['requestState.error'] = 'Request failed'\n" +
" __bs(update, endpointId, 'malformed', 'empty response')\n" +
" } else {\n" +
" update['backend.' + endpointId + '.state'] = 'ready'\n" +
" update['backend.' + endpointId + '.response'] = res.data\n" +
" update['backend.' + endpointId + '.error'] = ''\n" +
" update['requestState.state'] = 'ready'\n" +
" update['requestState.error'] = ''\n" +
" __bs(update, endpointId, 'ready', '')\n" +
" if (endpoint.r) update[endpoint.r] = res.data\n" +
" const sessionId = __bunniaSessionIdFromResponse(res.data)\n" +
" if (res.data && res.data.state === 'revoked') __bunniaStoreSession('')\n" +
" else if (sessionId && __bunniaBackend.s) { update[__bunniaBackend.s] = sessionId; __bunniaStoreSession(sessionId) }\n" +
" if (typeof page.__bunniaResponsePatch === 'function') { try { Object.assign(update, page.__bunniaResponsePatch(endpointId, res.data) || {}) } catch (_) {} }\n" +
" }\n" +
" page.setData(update, () => { if (typeof settled === 'function') settled(update['requestState.state'] === 'ready', res.data) })\n" +
" },\n" +
" fail(err) {\n" +
" const update = {}\n" +
" const message = String(err.errMsg || err)\n" +
" update['backend.' + endpointId + '.state'] = message.indexOf('timeout') >= 0 ? 'timeout' : 'failed'\n" +
" update['backend.' + endpointId + '.error'] = message\n" +
" update['requestState.state'] = 'failed'\n" +
" update['requestState.error'] = 'Request failed'\n" +
" update['requestState.statusCode'] = 0\n" +
" __bs(update, endpointId, update['backend.' + endpointId + '.state'], message)\n" +
" page.setData(update, () => { if (typeof settled === 'function') settled(false, err) })\n" +
" }\n" +
" })\n" +
"}\n" +
"\n" +
"function __bunniaRequestHeaders(page, endpointId) {\n" +
" const k = __bunniaBackend.s, h = __bunniaBackend.h\n" +
" if (page && typeof page.__bunniaSessionTokenForEndpoint === 'function') {\n" +
" let scoped\n" +
" try { scoped = page.__bunniaSessionTokenForEndpoint(endpointId) } catch (_) { scoped = undefined }\n" +
" if (scoped !== undefined && scoped !== null) return scoped && h ? { [h]: String(scoped) } : {}\n" +
" }\n" +
" const t = (k && page && page.data ? page.data[k] : '') || __bunniaStoredSession()\n" +
" return t && h ? { [h]: t } : {}\n" +
"}\n" +
"\n" +
"function __bunniaStoredSession() {\n" +
" try { return typeof wx !== 'undefined' && wx.getStorageSync ? (wx.getStorageSync(__bunniaSessionStorageKey) || '') : '' } catch (_) { return '' }\n" +
"}\n" +
"\n" +
"function __bunniaStoreSession(value) {\n" +
" try { if (typeof wx !== 'undefined' && wx.setStorageSync) wx.setStorageSync(__bunniaSessionStorageKey, value || '') } catch (_) {}\n" +
"}\n" +
"\n" +
"function __bunniaSessionIdFromResponse(data) {\n" +
" return data && data.session && data.session.id ? data.session.id : (data && data.sessionId ? data.sessionId : (data && data.id && data.kind === 'session' ? data.id : ''))\n" +
"}\n" +
"\n" +
"function __bs(update, endpointId, state, error) {\n" +
" const p = 'backendStatus.' + String(endpointId).replace(/[^A-Za-z0-9_]/g, '_')\n" +
" update[p + '.state'] = state\n" +
" update[p + '.error'] = error || ''\n" +
"}\n" +
"\n" +
"function __bunniaCancelRequest(page, endpointId) {\n" +
" const update = {}\n" +
" update['backend.' + endpointId + '.state'] = 'cancelled'\n" +
" update['backend.' + endpointId + '.error'] = ''\n" +
" update['requestState.state'] = 'cancelled'\n" +
" update['requestState.error'] = ''\n" +
" __bs(update, endpointId, 'cancelled', '')\n" +
" page.setData(update)\n" +
"}\n" +
"\n" +
"function __bunniaRetryRequest(page, endpointId) {\n" +
" const current = page.data.backend && page.data.backend[endpointId]\n" +
" return __bunniaRequest(page, endpointId, current ? current.request : undefined)\n" +
"}\n" +
"\n" +
"module.exports = { __bunniaBackend, __bunniaRequest, __bunniaCancelRequest, __bunniaRetryRequest, __bunniaStoredSession, __bk }"
}
///|
fn wechat_file(path : String, content : String) -> WechatFile {
{ path, content }
}
///|
fn append_packaged_scene_asset_files(
files : Array[WechatFile],
pages : Array[WechatProjectPage],
) -> Unit {
for page in pages {
for asset in page.page.scene_assets {
if scene_asset_should_be_packaged(asset) {
let path = scene_asset_file_path(asset.src)
if !wechat_file_seen(files, path) {
files.push(wechat_file(path, packaged_scene_asset_content(asset)))
}
}
}
}
}
///|
fn packaged_scene_asset_content(asset : WechatSceneAsset) -> String {
if asset.source_path != "" {
"copy-file:\{asset.source_path}"
} else {
packaged_scene_asset_png_base64()
}
}
///|
fn scene_asset_should_be_packaged(asset : WechatSceneAsset) -> Bool {
!asset.remote &&
asset.src.has_prefix("/assets/") &&
(
asset.src.has_suffix(".png") ||
asset.src.has_suffix(".jpg") ||
asset.src.has_suffix(".jpeg") ||
asset.src.has_suffix(".webp")
)
}
///|
fn scene_asset_file_path(src : String) -> String {
if src.has_prefix("/") {
src.sub(start=1, end=src.length()).to_owned()
} else {
src
}
}
///|
fn wechat_file_seen(files : Array[WechatFile], path : String) -> Bool {
for file in files {
if file.path == path {
return true
}
}
false
}
///|
fn packaged_scene_asset_png_base64() -> String {
"base64:iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/l0z9ZAAAAABJRU5ErkJggg=="
}
///|
fn materialized_file_bytes(
file : WechatFile,
pages : Array[WechatProjectPage],
) -> Int {
if file.content.has_prefix("copy-file:") {
for page in pages {
for asset in page.page.scene_assets {
if scene_asset_file_path(asset.src) == file.path {
return asset.estimated_bytes
}
}
}
}
file.content.length()
}
///|
fn total_file_bytes(
files : Array[WechatFile],
pages : Array[WechatProjectPage],
) -> Int {
let mut total = 0
for item in files {
total += materialized_file_bytes(item, pages)
}
total
}
///|
fn project_manifest(
name : String,
entry_route : String,
pages : Array[WechatProjectPage],
files : Array[WechatFile],
backend_contract : @effects.BackendContract?,
) -> WechatProjectManifest {
let app_total = total_file_bytes(files, pages)
let manifest_files = manifest_files(files, pages)
let manifest_pages = manifest_pages(pages, files, backend_contract)
let scene_assets = manifest_scene_assets(manifest_pages)
let scene_remote_asset_count = manifest_remote_scene_asset_count(scene_assets)
let scene_package_bytes = manifest_scene_package_bytes(scene_assets)
let diagnostic_route_count = manifest_diagnostic_route_count(manifest_pages)
let diagnostic_count = manifest_diagnostic_count(manifest_pages)
{
name,
entry_route,
page_count: pages.length(),
app_file_count: files.length(),
app_total_bytes: app_total,
scene_asset_count: scene_assets.length(),
scene_remote_asset_count,
scene_package_bytes,
diagnostic_route_count,
diagnostic_count,
pages: manifest_pages,
files: manifest_files,
scene_assets,
summary: "Bunnia WeChat manifest: pages=\{pages.length()} app_files=\{files.length()} app_bytes=\{app_total} scene_assets=\{scene_assets.length()} scene_package_bytes=\{scene_package_bytes} scene_remote_assets=\{scene_remote_asset_count} diagnostic_routes=\{diagnostic_route_count} diagnostics=\{diagnostic_count} entry=\{entry_route}",
}
}
///|
fn manifest_files(
files : Array[WechatFile],
pages : Array[WechatProjectPage],
) -> Array[WechatManifestFile] {
let output : Array[WechatManifestFile] = []
for file in files {
output.push({
path: file.path,
kind: manifest_file_kind(file.path),
route: manifest_file_route(file.path),
bytes: materialized_file_bytes(file, pages),
})
}
output
}
///|
fn manifest_pages(
pages : Array[WechatProjectPage],
files : Array[WechatFile],
backend_contract : @effects.BackendContract?,
) -> Array[WechatManifestPage] {
let output : Array[WechatManifestPage] = []
for item in pages {
let (
backend_events,
backend_streams,
backend_reviews,
backend_cancels,
backend_retries,
) = page_backend_pressure(item, backend_contract)
let scene_assets = page_manifest_scene_assets(item)
let diagnostics = page_manifest_diagnostics(item)
output.push({
name: item.name,
route: item.route,
node_count: item.page.plan.node_count,
depth: item.page.plan.depth,
windowed_list_count: item.page.plan.windowed_list_count,
list_total_count: item.page.plan.list_total_count,
list_visible_count: item.page.plan.list_visible_count,
unwindowed_list_child_count: item.page.plan.unwindowed_list_child_count,
unkeyed_list_child_count: item.page.plan.unkeyed_list_child_count,
duplicate_list_key_count: item.page.plan.duplicate_list_key_count,
component_count: item.page.plan.component_kinds.length(),
event_count: item.page.plan.event_messages.length(),
backend_event_count: backend_events,
backend_stream_count: backend_streams,
backend_review_count: backend_reviews,
backend_cancel_count: backend_cancels,
backend_retry_count: backend_retries,
scene_count: item.page.plan.scene_count,
scene_marker_count: item.page.plan.scene_marker_count,
scene_visible_marker_count: item.page.plan.scene_visible_marker_count,
scene_region_count: item.page.plan.scene_region_count,
scene_visible_region_count: item.page.plan.scene_visible_region_count,
scene_asset_count: item.page.plan.scene_asset_count,
scene_assets,
scene_package_bytes: manifest_scene_package_bytes(scene_assets),
scene_remote_asset_count: manifest_remote_scene_asset_count(scene_assets),
degraded_scene_count: item.page.plan.degraded_scene_count,
missing_event_patch_count: page_missing_event_patch_count(item),
orphan_event_patch_count: page_orphan_event_patch_count(item),
initial_data_bytes: item.runtime.initial_data_json.length(),
event_patch_bytes: page_event_patch_bytes(item),
event_patch_operation_count: page_event_patch_operation_count(item),
first_screen_bytes: page_first_screen_bytes(item, files),
update_payload_bytes: page_update_payload_bytes(item),
update_operation_count: page_update_operation_count(item),
file_bytes: route_file_bytes(item.route, files),
render_budget: item.page.render_budget,
render_diagnostics: item.page.plan.diagnostics,
diagnostics,
diagnostic_count: diagnostics.length(),
status: route_manifest_status(diagnostics),
})
}
output
}
///|
fn page_manifest_scene_assets(
page : WechatProjectPage,
) -> Array[WechatManifestSceneAsset] {
let output : Array[WechatManifestSceneAsset] = []
for item in page.page.scene_assets {
output.push({
route: page.route,
id: item.id,
kind: item.kind,
src: item.src,
remote: item.remote,
estimated_bytes: item.estimated_bytes,
})
}
output
}
///|
fn manifest_scene_assets(
pages : Array[WechatManifestPage],
) -> Array[WechatManifestSceneAsset] {
let output : Array[WechatManifestSceneAsset] = []
for page in pages {
for item in page.scene_assets {
output.push(item)
}
}
output
}
///|
fn manifest_remote_scene_asset_count(
items : Array[WechatManifestSceneAsset],
) -> Int {
let mut total = 0
for item in items {
if item.remote {
total += 1
}
}
total
}
///|
fn manifest_scene_package_bytes(items : Array[WechatManifestSceneAsset]) -> Int {
let mut total = 0
for item in items {
if !item.remote {
total += item.estimated_bytes
}
}
total
}
///|
fn route_manifest_status(diagnostics : Array[String]) -> String {
if diagnostics.length() == 0 {
"ok"
} else {
"diagnostic"
}
}
///|
fn manifest_diagnostic_route_count(pages : Array[WechatManifestPage]) -> Int {
let mut total = 0
for page in pages {
if page.diagnostic_count > 0 {
total += 1
}
}
total
}
///|
fn manifest_diagnostic_count(pages : Array[WechatManifestPage]) -> Int {
let mut total = 0
for page in pages {
total += page.diagnostic_count
}
total
}
///|
fn manifest_file_kind(path : String) -> String {
if path == "app.json" {
"app"
} else if path == "project.config.json" {
"project-config"
} else if path == "sitemap.json" {
"sitemap"
} else if path == "bunnia.backend.js" {
"backend-js"
} else if path.has_suffix(".wxml") {
"wxml"
} else if path.has_suffix(".wxss") {
"wxss"
} else if path.has_suffix(".data.js") {
"page-data"
} else if path.has_suffix(".js") {
"js"
} else if path.has_suffix(".json") {
"json"
} else {
"asset"
}
}
///|
fn manifest_file_route(path : String) -> String {
if path.has_suffix(".wxml") && path.has_prefix("pages/") {
trim_suffix(path, ".wxml")
} else if path.has_suffix(".wxss") && path.has_prefix("pages/") {
trim_suffix(path, ".wxss")
} else if path.has_suffix(".data.js") && path.has_prefix("pages/") {
trim_suffix(path, ".data.js")
} else if path.has_suffix(".js") && path.has_prefix("pages/") {
trim_suffix(path, ".js")
} else if path.has_suffix(".json") && path.has_prefix("pages/") {
trim_suffix(path, ".json")
} else {
""
}
}
///|
fn trim_suffix(value : String, suffix : String) -> String {
value.sub(start=0, end=value.length() - suffix.length()).to_owned()
}
///|
fn route_file_bytes(route : String, files : Array[WechatFile]) -> Int {
let mut total = 0
for file in files {
if manifest_file_route(file.path) == route {
total += file.content.length()
}
}
total
}
///|
fn page_event_patch_bytes(page : WechatProjectPage) -> Int {
let mut total = 0
for item in page.runtime.event_patches {
if page.page.plan.event_messages.contains(item.message) {
total += item.message.length()
for patch in item.patches {
total += patch.estimated_bytes
}
}
}
total
}
///|
fn page_event_patch_operation_count(page : WechatProjectPage) -> Int {
page_update_operation_count(page)
}
///|
fn page_update_operation_count(page : WechatProjectPage) -> Int {
let mut total = 0
for item in page.runtime.event_patches {
if page.page.plan.event_messages.contains(item.message) {
total += item.patches.length()
}
}
total
}
///|
fn page_missing_event_patch_count(page : WechatProjectPage) -> Int {
let mut total = 0
for message in page.page.plan.event_messages {
if !page_runtime_has_patch(page.runtime, message) {
total += 1
}
}
total
}
///|
fn page_orphan_event_patch_count(page : WechatProjectPage) -> Int {
let mut total = 0
for patch in page.runtime.event_patches {
if !page.page.plan.event_messages.contains(patch.message) {
total += 1
}
}
total
}
///|
fn page_runtime_has_patch(runtime : WechatRuntime, message : String) -> Bool {
for item in runtime.event_patches {
if item.message == message {
return true
}
}
false
}
///|
fn page_manifest_diagnostics(page : WechatProjectPage) -> Array[String] {
let output : Array[String] = []
for item in page.page.plan.diagnostics {
output.push("wechat-render:\{page.route}:\{item}")
}
let initial = page.runtime.initial_data_json.length()
let initial_limit = page.page.render_budget.max_initial_data_bytes
if initial > initial_limit {
output.push(
"wechat-route-initial-data-over-render-budget:\{page.route}:\{initial}/\{initial_limit}",
)
}
for message in page.page.plan.event_messages {
if !page_runtime_has_patch(page.runtime, message) {
output.push("wechat-event-without-patch:\{page.route}:\{message}")
}
}
for patch in page.runtime.event_patches {
if !page.page.plan.event_messages.contains(patch.message) {
output.push("wechat-patch-without-event:\{page.route}:\{patch.message}")
}
}
output
}
///|
fn page_backend_pressure(
page : WechatProjectPage,
contract : @effects.BackendContract?,
) -> (Int, Int, Int, Int, Int) {
match contract {
Some(value) => {
let mut event_count = 0
let mut stream_count = 0
let mut review_count = 0
let mut cancel_count = 0
let mut retry_count = 0
for endpoint in value.endpoints {
if page.page.plan.event_messages.contains(endpoint.id) {
event_count += 1
if endpoint.streaming {
stream_count += 1
}
if endpoint.requires_review {
review_count += 1
}
if endpoint.kind is @effects.AgentOperationCancel {
cancel_count += 1
}
if endpoint.kind is @effects.AgentOperationRetry {
retry_count += 1
}
}
}
(event_count, stream_count, review_count, cancel_count, retry_count)
}
None => (0, 0, 0, 0, 0)
}
}
///|
fn manifest_json(input : WechatProjectManifest) -> String {
"{\n" +
" \"name\": \"\{escape_json(input.name)}\",\n" +
" \"entryRoute\": \"\{escape_json(input.entry_route)}\",\n" +
" \"pageCount\": \{input.page_count},\n" +
" \"appFileCount\": \{input.app_file_count},\n" +
" \"appTotalBytes\": \{input.app_total_bytes},\n" +
" \"sceneAssetCount\": \{input.scene_asset_count},\n" +
" \"sceneRemoteAssetCount\": \{input.scene_remote_asset_count},\n" +
" \"scenePackageBytes\": \{input.scene_package_bytes},\n" +
" \"diagnosticRouteCount\": \{input.diagnostic_route_count},\n" +
" \"diagnosticCount\": \{input.diagnostic_count},\n" +
" \"pages\": \{manifest_pages_json(input.pages)},\n" +
" \"files\": \{manifest_files_json(input.files)},\n" +
" \"sceneAssets\": \{manifest_scene_assets_json(input.scene_assets)}\n" +
"}\n"
}
///|
fn manifest_pages_json(items : Array[WechatManifestPage]) -> String {
let out = StringBuilder::new()
out.write_string("[")
for i in 0.. 0 {
out.write_string(", ")
}
out.write_string(manifest_page_json(items[i]))
}
out.write_string("]")
out.to_string()
}
///|
fn manifest_page_json(item : WechatManifestPage) -> String {
"{" +
"\"name\":\"\{escape_json(item.name)}\"," +
"\"route\":\"\{escape_json(item.route)}\"," +
"\"nodes\":\{item.node_count}," +
"\"depth\":\{item.depth}," +
"\"windowedLists\":\{item.windowed_list_count}," +
"\"listTotal\":\{item.list_total_count}," +
"\"listVisible\":\{item.list_visible_count}," +
"\"unwindowedListChildren\":\{item.unwindowed_list_child_count}," +
"\"unkeyedListChildren\":\{item.unkeyed_list_child_count}," +
"\"duplicateListKeys\":\{item.duplicate_list_key_count}," +
"\"components\":\{item.component_count}," +
"\"events\":\{item.event_count}," +
"\"backendEvents\":\{item.backend_event_count}," +
"\"backendStreams\":\{item.backend_stream_count}," +
"\"backendReviews\":\{item.backend_review_count}," +
"\"backendCancels\":\{item.backend_cancel_count}," +
"\"backendRetries\":\{item.backend_retry_count}," +
"\"scenes\":\{item.scene_count}," +
"\"sceneMarkers\":\{item.scene_marker_count}," +
"\"sceneVisibleMarkers\":\{item.scene_visible_marker_count}," +
"\"sceneRegions\":\{item.scene_region_count}," +
"\"sceneVisibleRegions\":\{item.scene_visible_region_count}," +
"\"sceneAssets\":\{item.scene_asset_count}," +
"\"sceneAssetRefs\":\{manifest_scene_assets_json(item.scene_assets)}," +
"\"scenePackageBytes\":\{item.scene_package_bytes}," +
"\"sceneRemoteAssets\":\{item.scene_remote_asset_count}," +
"\"degradedScenes\":\{item.degraded_scene_count}," +
"\"missingEventPatches\":\{item.missing_event_patch_count}," +
"\"orphanEventPatches\":\{item.orphan_event_patch_count}," +
"\"initialDataBytes\":\{item.initial_data_bytes}," +
"\"eventPatchBytes\":\{item.event_patch_bytes}," +
"\"eventPatchOperations\":\{item.event_patch_operation_count}," +
"\"firstScreenBytes\":\{item.first_screen_bytes}," +
"\"updatePayloadBytes\":\{item.update_payload_bytes}," +
"\"updateOperations\":\{item.update_operation_count}," +
"\"fileBytes\":\{item.file_bytes}," +
"\"renderBudget\":\{render_budget_json(item.render_budget)}," +
"\"renderDiagnostics\":\{strings_json(item.render_diagnostics)}," +
"\"diagnostics\":\{strings_json(item.diagnostics)}," +
"\"diagnosticCount\":\{item.diagnostic_count}," +
"\"status\":\"\{escape_json(item.status)}\"" +
"}"
}
///|
fn manifest_scene_assets_json(
items : Array[WechatManifestSceneAsset],
) -> String {
let out = StringBuilder::new()
out.write_string("[")
for i in 0.. 0 {
out.write_string(", ")
}
out.write_string(manifest_scene_asset_json(items[i]))
}
out.write_string("]")
out.to_string()
}
///|
fn manifest_scene_asset_json(item : WechatManifestSceneAsset) -> String {
"{" +
"\"route\":\"\{escape_json(item.route)}\"," +
"\"id\":\"\{escape_json(item.id)}\"," +
"\"kind\":\"\{escape_json(item.kind)}\"," +
"\"src\":\"\{escape_json(item.src)}\"," +
"\"remote\":\{item.remote}," +
"\"estimatedBytes\":\{item.estimated_bytes}" +
"}"
}
///|
fn render_budget_json(input : @core.RenderBudget) -> String {
"{" +
"\"maxNodes\":\{input.max_nodes}," +
"\"maxDepth\":\{input.max_depth}," +
"\"maxEvents\":\{input.max_events}," +
"\"maxInitialDataBytes\":\{input.max_initial_data_bytes}," +
"\"maxUnwindowedListChildren\":\{input.max_unwindowed_list_children}," +
"\"maxSceneMarkers\":\{input.max_scene_markers}," +
"\"maxSceneRegions\":\{input.max_scene_regions}," +
"\"maxSceneAssets\":\{input.max_scene_assets}," +
"\"maxDegradedScenes\":\{input.max_degraded_scenes}" +
"}"
}
///|
fn strings_json(items : Array[String]) -> String {
let out = StringBuilder::new()
out.write_string("[")
for i in 0.. 0 {
out.write_string(", ")
}
out.write_string("\"")
out.write_string(escape_json(items[i]))
out.write_string("\"")
}
out.write_string("]")
out.to_string()
}
///|
fn manifest_files_json(items : Array[WechatManifestFile]) -> String {
let out = StringBuilder::new()
out.write_string("[")
for i in 0.. 0 {
out.write_string(", ")
}
out.write_string(manifest_file_json(items[i]))
}
out.write_string("]")
out.to_string()
}
///|
fn manifest_file_json(item : WechatManifestFile) -> String {
"{" +
"\"path\":\"\{escape_json(item.path)}\"," +
"\"kind\":\"\{escape_json(item.kind)}\"," +
"\"route\":\"\{escape_json(item.route)}\"," +
"\"bytes\":\{item.bytes}" +
"}"
}
///|
fn app_json(pages : Array[WechatProjectPage]) -> String {
"{\n" +
" \"pages\": \{page_routes_json(pages)},\n" +
" \"window\": {\n" +
" \"navigationBarTitleText\": \"Bunnia\",\n" +
" \"navigationBarBackgroundColor\": \"#ffffff\",\n" +
" \"navigationBarTextStyle\": \"black\"\n" +
" }\n" +
"}\n"
}
///|
fn page_routes_json(pages : Array[WechatProjectPage]) -> String {
let out = StringBuilder::new()
out.write_string("[")
for i in 0.. 0 {
out.write_string(", ")
}
out.write_string("\"")
out.write_string(escape_json(pages[i].route))
out.write_string("\"")
}
out.write_string("]")
out.to_string()
}
///|
fn project_config_json(name : String) -> String {
"{\n" +
" \"appid\": \"\",\n" +
" \"projectname\": \"\{escape_json(name)}\",\n" +
" \"compileType\": \"miniprogram\",\n" +
" \"setting\": {\n" +
" \"es6\": true,\n" +
" \"minified\": false,\n" +
" \"urlCheck\": false\n" +
" }\n" +
"}\n"
}
///|
fn sitemap_json() -> String {
"{\n" +
" \"rules\": [\n" +
" { \"action\": \"allow\", \"page\": \"*\" }\n" +
" ]\n" +
"}\n"
}
///|
fn compact_wxss(input : String) -> String {
let out = StringBuilder::new()
for i in 0.. Bool {
let prev = previous_non_css_space(input, i)
let next = next_non_css_space(input, i + 1)
prev != "" &&
next != "" &&
!css_space_optional_around(prev[0]) &&
!css_space_optional_around(next[0])
}
///|
fn previous_non_css_space(input : String, i : Int) -> String {
for offset in 1..<=i {
let idx = i - offset
if !is_css_space(input[idx]) {
return input.sub(start=idx, end=idx + 1).to_owned()
}
}
""
}
///|
fn next_non_css_space(input : String, start : Int) -> String {
for i in start.. Bool {
for i in start.. Bool {
let code = ch.to_int()
code == 32 || code == 10 || code == 9 || code == 13
}
///|
fn css_space_optional_around(ch : UInt16) -> Bool {
let code = ch.to_int()
code == 123 ||
code == 125 ||
code == 58 ||
code == 59 ||
code == 44 ||
code == 62 ||
code == 40 ||
code == 33
}
///|
fn patches_to_operations(patches : Array[@core.Patch]) -> String {
let out = StringBuilder::new()
out.write_string("[")
for i in 0.. 0 {
out.write_string(",")
}
out.write_string("{k:\"")
out.write_string(patch_kind_id(item.kind))
out.write_string("\",p:\"")
out.write_string(escape_json(item.path))
out.write_string("\",v:")
out.write_string(item.value_json)
out.write_string("}")
}
out.write_string("]")
out.to_string()
}
///|
fn collect_scene_assets(root : @core.Node) -> Array[WechatSceneAsset] {
let output : Array[WechatSceneAsset] = []
collect_scene_assets_into(output, root)
output
}
///|
fn collect_scene_assets_into(
output : Array[WechatSceneAsset],
root : @core.Node,
) -> Unit {
let id = attr_value(root.attrs, "data-asset-id")
if id != "" && !scene_asset_seen(output, id) {
output.push({
id,
kind: attr_value(root.attrs, "data-asset-kind"),
src: attr_value(root.attrs, "src"),
source_path: attr_value(root.attrs, "data-asset-source"),
remote: attr_value(root.attrs, "data-asset-remote") == "true",
estimated_bytes: attr_int_value(root.attrs, "data-asset-bytes"),
})
}
for child in root.children {
collect_scene_assets_into(output, child)
}
}
///|
fn attr_int_value(attrs : Array[@core.Attr], name : String) -> Int {
let value = attr_value(attrs, name)
if value == "" {
0
} else {
let mut total = 0
for i in 0.. 57 {
return 0
}
total = total * 10 + (code - 48)
}
total
}
}
///|
fn scene_asset_seen(items : Array[WechatSceneAsset], id : String) -> Bool {
for item in items {
if item.id == id {
return true
}
}
false
}
///|
fn attr_value(attrs : Array[@core.Attr], name : String) -> String {
for item in attrs {
if item.name == name {
return item.value
}
}
""
}
///|
fn patch_kind_id(kind : @core.PatchKind) -> String {
match kind {
Set => "set"
Append => "append"
Remove => "remove"
}
}
///|
fn render_node(root : @core.Node, adapter : @core.PlatformAdapter) -> String {
let kind = component_name(root.kind, adapter)
if kind == "page" {
render_children(root.children, adapter)
} else {
let attrs = render_attrs(root, adapter)
if root.children.length() == 0 && root.text == "" {
"<\{kind}\{attrs}>\{kind}>"
} else {
"<\{kind}\{attrs}>\{escape_wxml_text(root.text)}\{render_children(root.children, adapter)}\{kind}>"
}
}
}
///|
fn render_children(
children : Array[@core.Node],
adapter : @core.PlatformAdapter,
) -> String {
let out = StringBuilder::new()
for child in children {
out.write_string(render_node(child, adapter))
}
out.to_string()
}
///|
fn render_attrs(root : @core.Node, adapter : @core.PlatformAdapter) -> String {
let out = StringBuilder::new()
for attr in root.attrs {
out.write_string(
" \{attr.name}=\"\{escape_wxml_attr(wechat_attr_value(attr))}\"",
)
}
for binding in root.events {
let rendered_event = event_name(binding.event, adapter)
out.write_string(" \{rendered_event}=\"__bunniaEvent\"")
out.write_string(" data-bunnia-message=\"\{escape_xml(binding.message)}\"")
}
out.to_string()
}
///|
fn wechat_attr_value(attr : @core.Attr) -> String {
if attr.name == "style" {
compact_inline_style(attr.value)
} else {
attr.value
}
}
///|
fn compact_inline_style(value : String) -> String {
let compact = compact_wxss(value)
let length = compact.length()
if length > 0 && compact[length - 1].to_int() == 59 {
compact.sub(start=0, end=length - 1).to_owned()
} else {
compact
}
}
///|
fn event_name(event : String, adapter : @core.PlatformAdapter) -> String {
if event == "tap" {
adapter.tap_event
} else if event == "input" {
"bindinput"
} else if event == "submit" {
"bindsubmit"
} else {
event
}
}
///|
fn component_name(kind : String, adapter : @core.PlatformAdapter) -> String {
if kind == "page" {
adapter.page_component
} else if kind == "windowed-list" {
"\{adapter.component_prefix}view"
} else {
"\{adapter.component_prefix}\{kind}"
}
}
///|
fn page_js(
name : String,
plan : @core.RenderPlan,
runtime : WechatRuntime,
) -> String {
"const __bunniaEventPatches = \{event_patches_object(runtime.event_patches, plan)}\n" +
"\n" +
runtime_helpers_js() +
"\n" +
page_definition_js(
name,
plan,
runtime.initial_data_json,
"",
runtime.page_methods_js,
)
}
///|
fn project_page_js(
name : String,
route : String,
plan : @core.RenderPlan,
runtime : WechatRuntime,
) -> String {
"const { __bunniaApplyPatches } = require('\{runtime_require_path(route)}')\n" +
"const { initialData: __bunniaInitialData, eventPatches: __bunniaEventPatches } = require('\{page_payload_require_path(route)}')\n" +
"\n" +
page_definition_js(
name,
plan,
"__bunniaInitialData",
"",
runtime.page_methods_js,
)
}
///|
fn project_page_js_with_backend(
name : String,
route : String,
plan : @core.RenderPlan,
runtime : WechatRuntime,
contract : @effects.BackendContract,
) -> String {
"const { __bunniaApplyPatches } = require('\{runtime_require_path(route)}')\n" +
"const { initialData: __bunniaInitialData, eventPatches: __bunniaEventPatches } = require('\{page_payload_require_path(route)}')\n" +
"const { __bunniaBackend, __bunniaRequest, __bunniaRetryRequest, __bunniaStoredSession, __bk } = require('\{backend_require_path(route)}')\n" +
"const __bunniaInitialDataWithSession = Object.assign({}, __bunniaInitialData)\n" +
"if (__bunniaBackend.s) __bunniaInitialDataWithSession[__bunniaBackend.s] = __bunniaStoredSession()\n" +
"const __bunniaBackendEvents = \{backend_event_object(contract, plan)}\n" +
"\n" +
backend_request_dispatch_js() +
"\n" +
page_definition_js(
name,
plan,
"__bunniaInitialDataWithSession",
" const formValues=event&&event.detail&&event.detail.value&&typeof event.detail.value==='object'?event.detail.value:{}\n" +
" __bunniaMaybeRequest(this,message,Object.assign({},formValues,event.currentTarget.dataset||{}))\n",
backend_session_page_methods_js() + runtime.page_methods_js,
)
}
///|
fn backend_session_page_methods_js() -> String {
" onShow(){\n" +
" const token=__bunniaStoredSession()\n" +
" if(__bunniaBackend.s&&token&&this.data[__bunniaBackend.s]!==token){const update={};update[__bunniaBackend.s]=token;this.setData(update)}\n" +
" if(typeof this.__bunniaPageShown==='function')this.__bunniaPageShown()\n" +
" },\n"
}
///|
fn page_payload_js(runtime : WechatRuntime, plan : @core.RenderPlan) -> String {
"const initialData = (\{runtime.initial_data_json})\n" +
"const eventPatches = \{event_patches_object(runtime.event_patches, plan)}\n" +
"\n" +
"module.exports = { initialData, eventPatches }\n"
}
///|
fn runtime_js() -> String {
runtime_helpers_js() +
"\n" +
"module.exports = { __bunniaReadPath, __bunniaApplyPatches }\n"
}
///|
fn runtime_helpers_js() -> String {
"function __bunniaReadPath(root, path) {\n" +
" return String(path).split('.').reduce((value, part) => value == null ? undefined : value[part], root)\n" +
"}\n" +
"\n" +
"function __bunniaApplyPatches(page, operations) {\n" +
" const update = {}\n" +
" ;(operations || []).forEach((patch) => {\n" +
" if (patch.k === 'append') {\n" +
" const current = __bunniaReadPath(page.data, patch.p)\n" +
" update[patch.p] = Array.isArray(current) ? current.concat([patch.v]) : [patch.v]\n" +
" } else if (patch.k === 'remove') {\n" +
" update[patch.p] = null\n" +
" } else {\n" +
" update[patch.p] = patch.v\n" +
" }\n" +
" })\n" +
" page.setData(update)\n" +
"}\n"
}
///|
fn page_definition_js(
name : String,
plan : @core.RenderPlan,
initial_data_expr : String,
after_event_js : String,
page_methods_js : String,
) -> String {
"Page({\n" +
" data: {\n" +
" __bunnia: {\n" +
" name: \"\{escape_json(name)}\",\n" +
" nodes: \{plan.node_count},\n" +
" depth: \{plan.depth},\n" +
" lastMessage: \"\",\n" +
" handledEvents: 0\n" +
" },\n" +
" ...(\{initial_data_expr})\n" +
" },\n" +
page_methods_js +
" __bunniaEvent(event) {\n" +
" const message = event.currentTarget.dataset.bunniaMessage\n" +
" const patches = __bunniaEventPatches[message] || []\n" +
" const inputPath = event.currentTarget.dataset.bunniaInputPath\n" +
" const inputPatches = inputPath && event.detail && event.detail.value !== undefined ? [{ k: 'set', p: inputPath, v: event.detail.value }] : []\n" +
" const setPath = event.currentTarget.dataset.bunniaSetPath\n" +
" const setRaw = event.currentTarget.dataset.bunniaSetValue\n" +
" let setValue = setRaw\n" +
" if (setPath && event.currentTarget.dataset.bunniaSetJson === 'true') { try { setValue = JSON.parse(setRaw) } catch (_) {} }\n" +
" const setPatches = setPath ? [{ k: 'set', p: setPath, v: setValue }] : []\n" +
" __bunniaApplyPatches(this, [\n" +
" { k: 'set', p: '__bunnia.lastMessage', v: message },\n" +
" { k: 'set', p: '__bunnia.handledEvents', v: (this.data.__bunnia.handledEvents || 0) + 1 },\n" +
" ...patches,\n" +
" ...inputPatches,\n" +
" ...setPatches\n" +
" ])\n" +
after_event_js +
" const route = event.currentTarget.dataset.bunniaRoute\n" +
" if (route && route !== this.route && typeof wx !== 'undefined' && wx.redirectTo) {\n" +
" wx.redirectTo({ url: route[0] === '/' ? route : '/' + route })\n" +
" }\n" +
" }\n" +
"})"
}
///|
fn backend_request_dispatch_js() -> String {
"function __bp(p,e,d){let h=0,b=e.p?Object.assign({},p.data[e.p]||{}):{};__bk.map(k=>d&&d[k]!==undefined&&d[k]!==''&&(b[k]=d[k],h=1));return h?b:undefined}\n" +
"function __bunniaMaybeRequest(p,m,d){\n" +
" const id=__bunniaBackendEvents[m]\n" +
" if(!id)return\n" +
" const e=__bunniaBackend.e[id]\n" +
" if(!e)return\n" +
" const raw=__bp(p,e,d)\n" +
" const send=()=>{const source=raw===undefined?(e.p?p.data[e.p]:{}):raw;const pl=typeof p.__bunniaMapRequest==='function'?p.__bunniaMapRequest(id,source):raw;if(e.k==='agent-operation-cancel')return __bunniaRequest(p,id,pl);if(e.k==='agent-operation-retry')return pl?__bunniaRequest(p,id,pl):__bunniaRetryRequest(p,id);return __bunniaRequest(p,id,pl)}\n" +
" if(typeof p.__bunniaBeforeRequest==='function'&&p.__bunniaBeforeRequest(id,raw,send))return\n" +
" return send()\n" +
"}\n"
}
///|
fn page_payload_file_path(route : String) -> String {
"\{route}.data.js"
}
///|
fn page_payload_require_path(route : String) -> String {
"./\{route_basename(route)}.data.js"
}
///|
fn route_basename(route : String) -> String {
let mut start = 0
for i in 0.. String {
let depth = route_slash_count(route)
if depth == 0 {
"./bunnia.runtime.js"
} else {
let out = StringBuilder::new()
for _ in 0.. String {
let depth = route_slash_count(route)
if depth == 0 {
"./bunnia.backend.js"
} else {
let out = StringBuilder::new()
for _ in 0.. Int {
let mut count = 0
for i in 0.. String {
let out = StringBuilder::new()
out.write_string("{")
let mut emitted = 0
for item in items {
if plan.event_messages.contains(item.message) {
if emitted > 0 {
out.write_string(",")
}
emitted += 1
out.write_string("\"")
out.write_string(escape_json(item.message))
out.write_string("\":")
out.write_string(patches_to_operations(item.patches))
}
}
out.write_string("}")
out.to_string()
}
///|
fn backend_event_object(
contract : @effects.BackendContract,
plan : @core.RenderPlan,
) -> String {
let out = StringBuilder::new()
out.write_string("{")
let mut emitted = 0
for endpoint in contract.endpoints {
if plan.event_messages.contains(endpoint.id) {
if emitted > 0 {
out.write_string(",")
}
emitted += 1
out.write_string("\"")
out.write_string(escape_json(endpoint.id))
out.write_string("\":\"")
out.write_string(escape_json(endpoint.id))
out.write_string("\"")
}
}
out.write_string("}")
out.to_string()
}
///|
fn backend_contract_object(contract : @effects.BackendContract) -> String {
"{b:\"" +
escape_json(contract.base_url_key) +
"\",s:\"" +
escape_json(contract.session_token_key) +
"\",h:\"" +
escape_json(contract.session_header_key) +
"\",e:" +
backend_endpoints_object(contract.endpoints) +
"}"
}
///|
fn backend_endpoints_object(items : Array[@effects.BackendEndpoint]) -> String {
let out = StringBuilder::new()
out.write_string("{")
for i in 0.. 0 {
out.write_string(",")
}
out.write_string("\"")
out.write_string(escape_json(item.id))
out.write_string("\":")
out.write_string(backend_endpoint_object(item))
}
out.write_string("}")
out.to_string()
}
///|
fn backend_endpoint_object(item : @effects.BackendEndpoint) -> String {
"{" +
"k:\"" +
escape_json(@effects.backend_endpoint_kind_id(item.kind)) +
"\"," +
"m:\"" +
escape_json(@effects.http_method_id(item.http_method)) +
"\"," +
"u:\"" +
escape_json(item.path) +
"\"," +
"p:\"" +
escape_json(item.payload_key) +
"\"," +
"r:\"" +
escape_json(item.response_key) +
"\"," +
"t:" +
item.timeout_ms.to_string() +
"}"
}
///|
fn default_wxss() -> String {
".demo-shell { min-height: 100vh; padding: 24rpx; box-sizing: border-box; display: flex; flex-direction: column; gap: 20rpx; background: #f7f8fa; color: #111827; }\n" +
".bunnia-message-feed { display: flex; flex-direction: column; gap: 12rpx; }\n" +
".bunnia-message-feed-windowed { max-height: 720rpx; }\n" +
".bunnia-message { padding: 16rpx 20rpx; border: 1rpx solid #e5e7eb; border-radius: 8rpx; background: #ffffff; box-sizing: border-box; font-size: 28rpx; line-height: 1.5; }\n" +
".bunnia-run-running { border-color: #2563eb; box-shadow: 0 0 0 2rpx rgba(37, 99, 235, 0.10); }\n" +
".bunnia-run-waiting-review { border-color: #d97706; }\n" +
".bunnia-run-failed { border-color: #dc2626; }\n" +
".bunnia-artifact-link { margin-top: 8rpx; color: #2563eb; font-size: 24rpx; }\n" +
".bunnia-thread { padding: 16rpx 20rpx; border: 1rpx solid #d1d5db; border-radius: 8rpx; background: #ffffff; box-sizing: border-box; font-size: 28rpx; line-height: 1.5; }\n" +
".bunnia-subject-link { margin-top: 8rpx; color: #0891b2; font-size: 24rpx; }\n" +
".bunnia-communication-trace { display: flex; flex-direction: column; gap: 12rpx; }\n" +
".bunnia-communication-trace-windowed { max-height: 720rpx; overflow: hidden; }\n" +
".bunnia-communication { padding: 14rpx 18rpx; border-left: 6rpx solid #64748b; border-radius: 8rpx; background: #ffffff; box-sizing: border-box; font-size: 26rpx; line-height: 1.45; }\n" +
".bunnia-communication-message { border-left-color: #64748b; }\n" +
".bunnia-communication-handoff { border-left-color: #0891b2; }\n" +
".bunnia-communication-broadcast { border-left-color: #2563eb; }\n" +
".bunnia-communication-review-request { border-left-color: #d97706; }\n" +
".bunnia-communication-approval { border-left-color: #16a34a; }\n" +
".bunnia-communication-tool-result { border-left-color: #7c3aed; }\n" +
".bunnia-communication-recovery-notice { border-left-color: #dc2626; }\n" +
".bunnia-list-windowed { display: flex; flex-direction: column; gap: 12rpx; }\n" +
".bunnia-status-surface { position: relative; display: flex; flex-direction: column; gap: 10rpx; }\n" +
".bunnia-status-badge { align-self: flex-start; padding: 8rpx 12rpx; border: 1rpx solid #94a3b8; border-radius: 8rpx; background: #ffffff; color: #111827; font-size: 24rpx; line-height: 1.35; box-sizing: border-box; }\n" +
".bunnia-status-detail { margin-top: 2rpx; color: #475569; font-size: 22rpx; }\n" +
".bunnia-surface-state-loading { border-color: #2563eb; }\n" +
".bunnia-surface-state-ready { border-color: #16a34a; }\n" +
".bunnia-surface-state-stale { border-color: #d97706; }\n" +
".bunnia-surface-state-error { border-color: #dc2626; }\n" +
".bunnia-surface-state-retry { border-color: #0891b2; }\n" +
".bunnia-surface-state-cancelled { border-color: #64748b; }\n" +
".bunnia-surface-state-degraded { border-color: #7c3aed; }\n" +
".bunnia-review { display: flex; gap: 12rpx; align-items: center; padding: 16rpx; border: 1rpx solid #e5e7eb; border-radius: 8rpx; background: #ffffff; box-sizing: border-box; }\n" +
".bunnia-review-button { min-height: 88rpx; padding: 8rpx 14rpx; border: 1rpx solid #94a3b8; border-radius: 8rpx; background: #ffffff; color: #111827; font-size: 24rpx; line-height: 1.35; }\n" +
".bunnia-review-button-disabled { color: #64748b; background: #f1f5f9; }\n" +
".bunnia-review-pending { border-color: #d97706; }\n" +
".bunnia-review-approved { border-color: #16a34a; }\n" +
".bunnia-review-rejected { border-color: #dc2626; }\n" +
".bunnia-review-cancelled { border-color: #64748b; }\n" +
".bunnia-review-failed { border-color: #dc2626; background: #fef2f2; }\n" +
".bunnia-scene { position: relative; overflow: hidden; max-width: 100%; border: 1rpx solid #d1d5db; border-radius: 8rpx; background: #eef6f3; box-sizing: border-box; }\n" +
".bunnia-scene-layer { position: absolute; left: 0; top: 0; right: 0; bottom: 0; }\n" +
".bunnia-scene-marker { position: absolute; transform: translate(-50%, -50%); min-width: 120rpx; min-height: 88rpx; padding: 10rpx 14rpx; border: 2rpx solid #64748b; border-radius: 8rpx; background: #ffffff; box-sizing: border-box; font-size: 24rpx; line-height: 1.4; text-align: center; }\n" +
".bunnia-scene-sprite { display: block; width: 48rpx; height: 48rpx; margin: 0 auto 4rpx; }\n" +
".bunnia-scene-with-threads { position: relative; display: block; }\n" +
".bunnia-scene-thread-overlay { display: flex; flex-wrap: wrap; gap: 8rpx; }\n" +
".bunnia-scene-thread-overlay-anchored { position: absolute; left: 0; top: 0; right: 0; bottom: 0; display: block; pointer-events: none; }\n" +
".bunnia-scene-thread { min-width: 88rpx; min-height: 88rpx; padding: 6rpx 10rpx; border: 1rpx solid #64748b; border-radius: 8rpx; background: #ffffff; color: #111827; font-size: 22rpx; line-height: 1.35; text-align: center; box-sizing: border-box; }\n" +
".bunnia-scene-thread-anchored { position: absolute; transform: translate(-50%, -50%); pointer-events: auto; }\n" +
".bunnia-scene-thread-running { border-color: #2563eb; }\n" +
".bunnia-scene-thread-review { border-color: #d97706; }\n" +
".bunnia-scene-thread-failed { border-color: #dc2626; }\n" +
".bunnia-status-stable { border-color: #16a34a; }\n" +
".bunnia-status-review { border-color: #d97706; }\n" +
".bunnia-status-running { border-color: #2563eb; }\n" +
".bunnia-status-selected { border-color: #0891b2; background: #ecfeff; }\n" +
".bunnia-status-default { border-color: #64748b; }"
}
///|
fn escape_xml(value : String) -> String {
value
.replace_all(old="&", new="&")
.replace_all(old="<", new="<")
.replace_all(old=">", new=">")
.replace_all(old="\"", new=""")
.to_string()
}
///|
/// WeChat parses `{{ ... }}` as source code inside a WXML attribute. Encoding
/// expression operators such as `&&`, `<`, or `>` as XML entities makes the
/// WXML compiler reject otherwise valid bindings. Literal attributes retain
/// full XML escaping; binding attributes only need their outer quote escaped.
fn escape_wxml_attr(value : String) -> String {
if value.contains("{{") && value.contains("}}") {
value.replace_all(old="\"", new=""")
} else {
escape_xml(value)
}
}
///|
/// Text nodes may mix literal copy with one or more `{{ ... }}` expressions.
/// Escape only the literal segments; expression operators must reach the WXML
/// compiler unchanged.
fn escape_wxml_text(value : String) -> String {
let out = StringBuilder::new()
let mut rest = value
while !rest.is_empty() {
match rest.find("{{") {
None => {
out.write_string(escape_xml(rest))
rest = ""
}
Some(start) => {
out.write_string(escape_xml(rest.sub(start=0, end=start).to_owned()))
let binding = rest.sub(start~, end=rest.length()).to_owned()
match binding.find("}}") {
None => {
out.write_string(escape_xml(binding))
rest = ""
}
Some(close) => {
let binding_end = close + 2
out.write_string(binding.sub(start=0, end=binding_end).to_owned())
rest = binding
.sub(start=binding_end, end=binding.length())
.to_owned()
}
}
}
}
}
out.to_string()
}
///|
fn escape_json(value : String) -> String {
value
.replace(old="\\", new="\\\\")
.replace(old="\"", new="\\\"")
.replace(old="\n", new="\\n")
.to_string()
}