From f2940aebf8e4575f25af80bd3b86665e08b78f5a Mon Sep 17 00:00:00 2001 From: tiye Date: Sat, 13 Dec 2025 15:59:54 +0800 Subject: [PATCH 1/3] upgrade deps --- .gitattributes | 1 + .github/workflows/release.yml | 30 ++ Agents.md | 538 ++++++++++++++++++++++++++++++++++ moon.mod.json | 6 +- src/main/container.mbt | 3 +- src/main/main.mbt | 3 +- src/main/store.mbt | 9 +- 7 files changed, 580 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 Agents.md diff --git a/.gitattributes b/.gitattributes index 63a9c23..5a68b91 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,3 +2,4 @@ calcit.cirru -diff linguist-generated yarn.lock -diff linguist-generated LICENSE -diff linguist-generated +Agents.md -diff linguist-generated diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..31138b3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,30 @@ +name: release + +on: + release: + types: [published] + +jobs: + publish: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + + - name: install moonbit + run: | + curl -fsSL https://cli.moonbitlang.com/install/unix.sh | bash + echo "$HOME/.moon/bin" >> $GITHUB_PATH + + - name: moon check + run: moon update && moon version && moon check --target js + + - name: moon test + run: moon build --target js --debug + + - name: setup credentials + run: | + mkdir -p ~/.moon + echo '${{ secrets.MOON_CREDENTIALS }}' > ~/.moon/credentials.json + + - name: moon publish + run: moon publish diff --git a/Agents.md b/Agents.md new file mode 100644 index 0000000..a65bc0a --- /dev/null +++ b/Agents.md @@ -0,0 +1,538 @@ +# Respo MoonBit 应用开发指南 (for LLM Agents) + +本文档面向 LLM 编程助手,介绍如何使用 Respo 框架编写 MoonBit 前端应用组件。 + +## 概述 + +Respo 是一个虚拟 DOM 框架,采用类似 React 的函数式组件设计模式,但使用 MoonBit 语言编写,编译为 JavaScript 运行于浏览器。 + +核心特点: + +- **函数式组件**:组件是返回 `RespoNode[ActionOp]` 的函数 +- **不可变数据设计**:Store 和 State 必须是不可变的,使用 record update 语法 `{ ..old, field: value }` 创建新实例 +- **全局 Store + 局部 States Tree**:状态存储在树结构中,使用 cursor 进行定位 +- **声明式 UI**:使用 DSL 风格的元素构建函数 +- **引用相等优化**:由于数据不可变,未变化的引用可以跳过重绘 + +## 依赖导入 + +常用导入模式: + +```moonbit +///| +using @respo_node { + type RespoNode, + type RespoEvent, + type DispatchFn, + type RespoCommonError, + text_node, + div, + input, + button, + span, + space, + static_style, + respo_attrs, +} + +///| +using @css {respo_style} + +///| +using @respo {type RespoStatesTree} +``` + +## 组件结构 + +### 基本组件函数 + +组件是一个函数,接收 states 和 props 参数,返回 `RespoNode[ActionOp]`: + +```moonbit +///| +fn comp_example( + states : RespoStatesTree, + some_prop : String, +) -> RespoNode[ActionOp] { + div([ + text_node("Hello, \{some_prop}"), + ]) +} +``` + +### 局部状态 (Local State) + +使用 `states.local_pair()` 获取局部状态和 cursor: + +```moonbit +///| +struct MyState { + count : Int +} derive(Default, ToJson, FromJson) + +///| +fn comp_counter(states : RespoStatesTree) -> RespoNode[ActionOp] { + // 获取局部状态和 cursor(第三个值是可选的更新后的 tree,通常忽略) + let ((state : MyState), cursor) = states.local_pair() + + div([ + text_node("Count: \{state.count}"), + button( + inner_text="Increment", + class_name=@respo.ui_button, + on_click=fn(_e, dispatch) { + // 更新局部状态 + dispatch.set_state(cursor, { count: state.count + 1 }) + }, + ), + ]) +} +``` + +**注意事项**: + +- State 结构体必须 derive `Default`, `ToJson`, `FromJson` +- 使用 `dispatch.set_state(cursor, new_state)` 更新状态 +- `local_pair()` 返回二元组:`(state, cursor)` +- 更新状态时使用 record update 语法:`{ ..state, field: new_value }` + +### 子状态传递 + +向子组件传递 states 时使用 `states.pick("key")`: + +```moonbit +///| +fn comp_parent(states : RespoStatesTree) -> RespoNode[ActionOp] { + div([ + comp_child(states.pick("child1")), + comp_child(states.pick("child2")), + ]) +} +``` + +## 元素构建 + +### 常用元素函数 + +```moonbit +// div 容器 +div(class_name="my-class", style=respo_style(padding=Px(8)), [ + // children... +]) + +// 带 key 的列表 (用于高效 diff) +div_listed([ + (RespoIndexKey("item-1"), comp_item(item1)), + (RespoIndexKey("item-2"), comp_item(item2)), +]) + +// span +span(inner_text="text content", []) + +// 文本节点 +text_node("Hello World") + +// 按钮 +button( + inner_text="Click me", + class_name=@respo.ui_button, + on_click=fn(e, dispatch) { /* handler */ }, +) + +// 输入框 +input( + class_name=@respo.ui_input, + value=state.value, + placeholder="Enter text...", + on_input=fn(e, dispatch) { + if e is Input(value~, ..) { + dispatch.set_state(cursor, { value: value }) + } + }, +) + +// 空白间隔 +space(width=8) // 水平间距 +space(height=8) // 垂直间距 +``` + +### 属性设置 + +```moonbit +// 使用 respo_attrs 创建属性 +div( + attrs={ + let t = respo_attrs(id="my-id", class_name="my-class") + t.set("data-custom", "value") + t + }, + [], +) +``` + +## 事件处理 + +### 事件类型 + +```moonbit +// 点击事件 +on_click=fn(e, dispatch) { + if e is Click(original_event~, ..) { + original_event.prevent_default() + } + dispatch.run(SomeAction) +} + +// 输入事件 +on_input=fn(e, dispatch) { + if e is Input(value~, ..) { + dispatch.set_state(cursor, { draft: value }) + } +} + +// 键盘事件 +on_keydown=fn(e, dispatch) { + if e is Keyboard(key~, ..) { + if key == "Enter" { + dispatch.run(SubmitAction) + } + } +} + +// 焦点事件 +on_focus=fn(e, dispatch) { /* ... */ } +on_blur=fn(e, dispatch) { /* ... */ } +``` + +### Dispatch 操作 + +```moonbit +// 分发全局 Action +dispatch.run(SomeAction) + +// 更新局部状态 +dispatch.set_state(cursor, new_state) + +// 清空局部状态 +dispatch.empty_state(cursor) +``` + +## 样式系统 + +### 内联样式 + +使用 `respo_style()` 创建样式: + +```moonbit +respo_style( + padding=Px(8), + margin=Px(4), + color=Hsl(200, 80, 50), + background_color=White, + display=Flex, + flex_direction=Column, + justify_content=Center, + align_items=Center, +) +``` + +### 静态样式 (CSS 类) + +使用 `static_style` 声明静态 CSS 规则: + +```moonbit +///| +let style_container : String = static_style([ + ("&", respo_style(margin=Px(4), background_color=Hsl(200, 90, 96))), + ("&:hover", respo_style(background_color=Hsl(200, 90, 90))), +]) + +// 使用 +div(class_name=style_container, []) +``` + +### 预定义 UI 类 + +```moonbit +@respo.ui_button // 按钮样式 +@respo.ui_button_primary // 主要按钮 +@respo.ui_button_danger // 危险按钮 +@respo.ui_input // 输入框样式 +@respo.ui_center // 居中布局 +@respo.ui_column // 列布局 +@respo.ui_row_middle // 行布局居中 +@respo.ui_row_parted // 行布局两端对齐 +@respo.ui_fullscreen // 全屏 +@respo.ui_global // 全局样式 +``` + +## 组件与 Effect + +### 命名组件 + +使用 `RespoComponent::named` 创建带 effect 的命名组件: + +```moonbit +///| +struct MyEffect { + some_data : String +} derive(ToJson) + +///| +impl @node.RespoEffect for MyEffect with mounted(_self, el) { + @dom_ffi.log("Component mounted") +} + +///| +impl @node.RespoEffect for MyEffect with updated(_self, el) { + @dom_ffi.log("Component updated") +} + +///| +fn comp_with_effect(states : RespoStatesTree) -> RespoNode[ActionOp] { + let effect_data : MyEffect = { some_data: "value" } + + @node.RespoComponent::named( + "my-component", + effects=[effect_data], + div([ + text_node("Component with effect"), + ]), + ).to_node() +} +``` + +### Effect 生命周期 + +```moonbit +impl @node.RespoEffect for MyEffect with mounted(_self, el) -> Unit { + // 组件挂载后 +} + +impl @node.RespoEffect for MyEffect with before_update(_self, el) -> Unit { + // 更新前 +} + +impl @node.RespoEffect for MyEffect with updated(_self, el) -> Unit { + // 更新后 +} + +impl @node.RespoEffect for MyEffect with before_unmount(_self, el) -> Unit { + // 卸载前 +} +``` + +## Store 和 Action + +### 定义 Store + +Store 是不可变的,使用 record update 语法 `{..self, field: value}` 创建新的 Store。集合类型使用 `@immut/array.T` 等不可变集合: + +```moonbit +///| +/// Store is immutable - all updates return new Store instances +struct Store { + count : Int + items : @immut/array.T[Item] // Use immutable collections + states : @respo.RespoStatesTree +} + +///| +impl Default for Store with default() -> Store { + { count: 0, items: @immut/array.new(), states: @respo.RespoStatesTree::default() } +} + +///| +fn Store::get_states(self : Store) -> @respo.RespoStatesTree { + self.states +} +``` + +### 定义 Action + +```moonbit +///| +enum ActionOp { + Noop + StatesChange(@respo.RespoUpdateState) + Increment + Decrement +} derive(Eq, ToJson) + +///| +impl Default for ActionOp with default() -> ActionOp { + Noop +} + +///| +impl @respo_node.RespoAction for ActionOp with build_states_action(cursor, a, j) { + StatesChange({ + cursor, + data: if a is Some(a) { Some(@dom_ffi.js_obscure_to_v(a)) } else { None }, + backup: j, + }) +} +``` + +### 更新 Store + +Store 的 `update` 方法返回新的 Store 实例,不修改原有实例: + +```moonbit +///| +/// Immutable update: returns a new Store with the action applied +fn Store::update(self : Store, op : ActionOp) -> Store { + match op { + Increment => { ..self, count: self.count + 1 } + Decrement => { ..self, count: self.count - 1 } + StatesChange(change) => { ..self, states: self.states.set_in(change) } + Noop => self + } +} +``` + +在 main 函数中,Store 通过 `Ref[Store]` 包装来支持可变引用: + +```moonbit +app.render_loop(fn() { view(app.store.val) }, fn(op) { + // 使用不可变更新,将新 Store 赋值给 Ref + app.store.val = app.store.val.update(op) +}) +``` + +**为什么要用不可变数据**: + +- 通过 `physical_equal` 快速检测变化,跳过不必要的重绘 +- 可预测的状态管理 +- 更容易实现时间旅行调试 + +## 记忆化 (Memoization) + +使用 `memo_once1` 等函数缓存组件以优化性能: + +```moonbit +///| +let memo_comp_panel : (RespoStatesTree) -> RespoNode[ActionOp] = @respo.memo_once1( + comp_panel, +) + +// 在父组件中使用 +fn view(states : RespoStatesTree) -> RespoNode[ActionOp] { + div([ + memo_comp_panel(states.pick("panel")), + ]) +} +``` + +可用的记忆化函数: + +- `memo_once1` ~ `memo_once5`:单槽缓存 +- `memoize1` ~ `memoize5`:多槽 hashmap 缓存 + +## 列表渲染 + +使用 `div_listed` 和 `RespoIndexKey` 进行高效列表渲染: + +```moonbit +///| +fn comp_list( + states : RespoStatesTree, + items : Array[Item], +) -> RespoNode[ActionOp] { + let children : Array[(RespoIndexKey, RespoNode[ActionOp])] = [] + for item in items { + children.push( + (RespoIndexKey(item.id), comp_item(states.pick(item.id), item)), + ) + } + div_listed(children) +} +``` + +## 常用模式 + +### 条件渲染 + +```moonbit +if condition { + div([content()]) +} else { + span([]) // 占位符 +} +``` + +### 外部 JS 函数 + +```moonbit +///| +extern "js" fn random_id() -> String = + #| () => Math.random().toString(36).slice(2) +``` + +### 日志输出 + +```moonbit +@dom_ffi.log("info message") +@dom_ffi.warn_log("warning message") +@dom_ffi.error_log("error message") +``` + +## 示例:完整组件 + +```moonbit +///| +struct FormState { + name : String + submitted : Bool +} derive(Default, ToJson, FromJson) + +///| +fn comp_form(states : RespoStatesTree) -> RespoNode[ActionOp] { + let ((state : FormState), cursor) = states.local_pair() + + div(class_name=@respo.ui_column, style=respo_style(padding=Px(16)), [ + input( + class_name=@respo.ui_input, + value=state.name, + placeholder="Enter your name", + on_input=fn(e, dispatch) { + if e is Input(value~, ..) { + dispatch.set_state(cursor, { ..state, name: value }) + } + }, + ), + space(height=8), + button( + inner_text="Submit", + class_name=@respo.ui_button_primary, + on_click=fn(_e, dispatch) { + dispatch.run(SubmitName(state.name)) + dispatch.set_state(cursor, { ..state, submitted: true }) + }, + ), + if state.submitted { + text_node("Submitted: \{state.name}") + } else { + span([]) + }, + ]) +} +``` + +## StateRef - 临时状态包装器 + +`StateRef[T]` 用于包装不参与序列化的临时状态(如动画偏移量、拖拽位置)。 + +```moonbit +///| +struct DemoState { + items : Array[Item] // 持久化数据 + drag_offset : @respo.StateRef[DragOffset] // 临时数据,不序列化 +} derive(Default, Eq, Hash, ToJson, FromJson) +``` + +**特性**:序列化时输出 `{}`,比较时忽略,页面刷新后重置为默认值。 + +**注意**:修改 `StateRef` 不会自动触发重绘,需手动调用 `@respo.mark_need_rerender()`。 + +使用 `moon doc "@respo.StateRef"` 查看完整 API。 diff --git a/moon.mod.json b/moon.mod.json index f682c08..c311761 100644 --- a/moon.mod.json +++ b/moon.mod.json @@ -2,9 +2,9 @@ "name": "tiye/respo-markdown", "version": "0.1.0", "deps": { - "tiye/respo": "0.1.2", - "tiye/dom-ffi": "0.2.1", - "tiye/respo_css": "0.1.2" + "tiye/respo": "0.2.2", + "tiye/dom-ffi": "0.2.3", + "tiye/respo_css": "0.1.4" }, "preferred-target": "js", "readme": "README.md", diff --git a/src/main/container.mbt b/src/main/container.mbt index 938140b..7ce3e0a 100644 --- a/src/main/container.mbt +++ b/src/main/container.mbt @@ -35,8 +35,7 @@ struct ContainerState { fn[Op : @respo_node.RespoAction] comp_container( states : RespoStatesTree, ) -> @respo_node.RespoNode[Op] { - let cursor = states.path() - let state = (states.cast_branch() : ContainerState) + let ((state : ContainerState), cursor) = states.local_pair() div( class_name=ui_global, style=respo_style( diff --git a/src/main/main.mbt b/src/main/main.mbt index e54b5a9..d0a28db 100644 --- a/src/main/main.mbt +++ b/src/main/main.mbt @@ -41,7 +41,8 @@ fn main { // @dom_ffi.log("store: " + app.store.val.to_json().stringify(indent=2)) app.render_loop(fn() { view(app.store.val) }, fn(op) { @dom_ffi.log("Action: " + op.to_string()) - app.store.val.update(op) + // 使用不可变更新,将新 Store 赋值给 Ref + app.store.val = app.store.val.update(op) }) let dev_mode = @dom_ffi.new_url_search_params(window.location().search()).get( "mode", diff --git a/src/main/store.mbt b/src/main/store.mbt index e68c95d..92ce720 100644 --- a/src/main/store.mbt +++ b/src/main/store.mbt @@ -1,8 +1,9 @@ ///| +/// Store is immutable - all updates return new Store instances struct Store { tasks : Array[Task] states : @respo.RespoStatesTree -} derive(ToJson, @json.FromJson) +} derive(ToJson, @json.FromJson, Eq) ///| impl Default for Store with default() -> Store { @@ -50,9 +51,9 @@ fn Store::get_states(self : Store) -> @respo.RespoStatesTree { } ///| -/// TODO mutation might break memoization infuture -fn Store::update(self : Store, op : ActionOp) -> Unit { +/// Immutable update: returns a new Store with the action applied +fn Store::update(self : Store, op : ActionOp) -> Store { match op { - StatesChange(states) => self.states.set_in_mut(states) + StatesChange(change) => { ..self, states: self.states.set_in(change) } } } From 065ea1625eac03e9a47c9ca13c88642947dd59cf Mon Sep 17 00:00:00 2001 From: tiye Date: Sat, 13 Dec 2025 16:01:49 +0800 Subject: [PATCH 2/3] handle warnings --- src/md.mbt | 4 ++-- src/util.mbt | 22 ++++++++++++++++------ 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/md.mbt b/src/md.mbt index 2efdcec..f1da46f 100644 --- a/src/md.mbt +++ b/src/md.mbt @@ -71,7 +71,7 @@ let style_snippet : String = static_style([ ///| fn[T] comp_image(chunk : String) -> @respo_node.RespoNode[T] { let useful = chunk.unsafe_substring(start=2, end=chunk.length() - 1) - let xs = useful.split("](").map(@string.View::to_string).collect() + let xs = useful.split("](").map(StringView::to_string).collect() if xs is [content, url, ..] { img(class_name=style_image, src=url, alt=content) } else { @@ -123,7 +123,7 @@ fn[T] comp_line(line : String) -> @respo_node.RespoNode[T] { ///| fn[T] comp_link(chunk : String) -> @respo_node.RespoNode[T] { let useful = chunk.unsafe_substring(start=1, end=chunk.length() - 1) - let xs = useful.split("](").map(@string.View::to_string).collect() + let xs = useful.split("](").map(StringView::to_string).collect() if xs is [content, url] { span(class_name=style_default_link, [ if content.has_prefix("`") && content.has_suffix("`") { diff --git a/src/util.mbt b/src/util.mbt index fbdac61..9e16259 100644 --- a/src/util.mbt +++ b/src/util.mbt @@ -57,7 +57,11 @@ fn split_block_iter( split_block_iter( left, acc, - Code(@immut/array.from_array([cursor.substring(start=3).to_string()])), + Code( + @immut/array.from_array([ + cursor.unsafe_substring(start=3, end=cursor.length()), + ]), + ), ) } else if is_table_line(cursor) { if cursor != "" { @@ -160,7 +164,7 @@ fn split_line_iter( } } let cursor = line.get_char(0).unwrap() - let left = line.substring(start=1).to_string() + let left = line.unsafe_substring(start=1, end=line.length()) // println("cursor: \{cursor} .. mode: \{mode} .. line: \{line}") match mode { Text(buffer) => @@ -211,12 +215,15 @@ fn split_line_iter( if left == "" { split_line_iter(acc, left, Text(buffer + "*")) } else { - let next_left = left.substring(start=1) + let next_left = left.unsafe_substring(start=1, end=left.length()) if left.has_prefix("*") { if match_string_pattern(next_left, peek_emphasis) is Some(matched) { println("emphasis: \{matched}") let emphasis = matched.selects[0] - let rest_line = next_left.substring(start=emphasis.length() + 2) + let rest_line = next_left.unsafe_substring( + start=emphasis.length() + 2, + end=next_left.length(), + ) acc.push(mode) acc.push(Emphasis(emphasis)) split_line_iter(acc, rest_line, Text("")) @@ -225,7 +232,10 @@ fn split_line_iter( } } else if match_string_pattern(left, peek_italic) is Some(matched) { let italic = matched.selects[0] - let rest_line = next_left.substring(start=italic.length()) + let rest_line = next_left.unsafe_substring( + start=italic.length(), + end=next_left.length(), + ) acc.push(mode) acc.push(Italic(italic)) split_line_iter(acc, rest_line, Text("")) @@ -249,7 +259,7 @@ fn split_line_iter( ///| fn split_table_content(cursor : String) -> Array[String] { cursor - .substring(start=1, end=cursor.length() - 1) + .unsafe_substring(start=1, end=cursor.length() - 1) .split("|") .map(fn(x) { x.trim(chars=" ").to_string() }) .to_array() From d800d0ba91c4680e6690bc945ca57e1be0d51e75 Mon Sep 17 00:00:00 2001 From: tiye Date: Sat, 13 Dec 2025 16:04:29 +0800 Subject: [PATCH 3/3] tag 0.1.1 --- README.md | 4 ++++ moon.mod.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a528af3..c21d31e 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ > also checks https://github.com/Respo/respo-markdown.calcit +```bash +moon add tiye/respo-markdown +``` + ````moonbit @markdown.comp_md("this is a `demo`") @markdown.comp_md("this is a `demo`", class_name=class_name) diff --git a/moon.mod.json b/moon.mod.json index c311761..b7f402a 100644 --- a/moon.mod.json +++ b/moon.mod.json @@ -1,6 +1,6 @@ { "name": "tiye/respo-markdown", - "version": "0.1.0", + "version": "0.1.1", "deps": { "tiye/respo": "0.2.2", "tiye/dom-ffi": "0.2.3",