stream_weaver 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (381) hide show
  1. checksums.yaml +7 -0
  2. data/.beads/.gitignore +77 -0
  3. data/.beads/README.md +81 -0
  4. data/.beads/config.yaml +54 -0
  5. data/.beads/metadata.json +7 -0
  6. data/.rspec +3 -0
  7. data/AGENTS.md +91 -0
  8. data/CHANGELOG.md +294 -0
  9. data/CLAUDE.md +67 -0
  10. data/LICENSE.txt +21 -0
  11. data/README.md +565 -0
  12. data/Rakefile +13 -0
  13. data/assets/streamweaver-hero.jpg +0 -0
  14. data/bench/baselines/ledger.rb +58 -0
  15. data/bench/baselines/warroom.rb +51 -0
  16. data/bench/fixtures/ledger.rb +76 -0
  17. data/bench/fixtures/warroom.rb +60 -0
  18. data/bench/results/6daf8b8.md +102 -0
  19. data/bench/results/c8d749b.md +102 -0
  20. data/bench/results/eb06ab9.md +102 -0
  21. data/bench/run.rb +151 -0
  22. data/bench/support.rb +84 -0
  23. data/copy_this/dto_run_sample_run_7631.html +220 -0
  24. data/docs/SERVICE_MODE.md +302 -0
  25. data/docs/architecture/how_streamweaver_works.md +772 -0
  26. data/docs/blog/2026-01-16-streamweaver-introduction.md +515 -0
  27. data/docs/bug-2026-08-31-canvas-bridge-socket-collision.md +83 -0
  28. data/docs/canvas-ipc-session-summary.md +127 -0
  29. data/docs/canvas-panel-workflow.md +172 -0
  30. data/docs/canvas-read.md +147 -0
  31. data/docs/canvas-roadmap.md +275 -0
  32. data/docs/case-studies/2026-09-03-didx-canvas-read-shelf.md +147 -0
  33. data/docs/claude-code-companion-skill-spec.md +567 -0
  34. data/docs/components_reference.md +1223 -0
  35. data/docs/crud-patterns.md +265 -0
  36. data/docs/endpoints.md +122 -0
  37. data/docs/for_llms.md +1 -0
  38. data/docs/form-patterns.md +165 -0
  39. data/docs/frontend-only.md +169 -0
  40. data/docs/html-artifact-audit.md +238 -0
  41. data/docs/ideas/2025-01-01-as-a-service.md +203 -0
  42. data/docs/ideas/2026-01-16-charm-tui-exploration.md +542 -0
  43. data/docs/ideas/2026-08-27-static-doc-shelf-export.md +153 -0
  44. data/docs/ideas/dhh-review-future-refactors.md +142 -0
  45. data/docs/opal-jamstack.md +47 -0
  46. data/docs/opal-npm-direction.md +184 -0
  47. data/docs/opal-spike-findings.md +194 -0
  48. data/docs/plans/2025-12-16-form-blocks-design.md +124 -0
  49. data/docs/plans/2025-12-30-examples-browser-design.md +152 -0
  50. data/docs/plans/2026-01-02-charts-design.md +288 -0
  51. data/docs/plans/2026-01-10-canvas-ipc-design.md +247 -0
  52. data/docs/plans/2026-01-19-cabinet-control-components.md +176 -0
  53. data/docs/plans/2026-01-26-gem-release-and-panel.md +140 -0
  54. data/docs/plans/canvas-claude-project-design.md +238 -0
  55. data/docs/plans/canvas-doc-location-and-discovery.md +32 -0
  56. data/docs/plans/org-doc-preview-surfaces.md +293 -0
  57. data/docs/plans/shadcn-polish-plan.md +417 -0
  58. data/docs/porting-artifacts.md +252 -0
  59. data/docs/reference/agent-skills-comparison.md +369 -0
  60. data/docs/reference/travel-state-prd.artifact.html +780 -0
  61. data/docs/research/2026-08-17-hotwire-alike-landscape.md +268 -0
  62. data/docs/research/2026-08-17-hotwire-concept-map.md +404 -0
  63. data/docs/research/2026-08-22-lazy-fragments-trigger-decision.md +246 -0
  64. data/docs/research/2026-08-22-learnhotwire-syllabus-coverage.md +239 -0
  65. data/docs/research/frontend-only-matrix.md +278 -0
  66. data/docs/research/streamweaver-way-spike-findings.md +529 -0
  67. data/docs/resource-dsl.md +342 -0
  68. data/docs/routing.md +302 -0
  69. data/docs/ruby-ui-comparison.md +319 -0
  70. data/docs/shared-dsl-fragments.md +356 -0
  71. data/docs/streamweaver-for-ai-agents.md +401 -0
  72. data/docs/streamweaver-frontend-vision.md +123 -0
  73. data/docs/streamweaver_canvas/glimmer_initial_final_layer.rb +77 -0
  74. data/docs/streamweaver_canvas/save_example.rb +9 -0
  75. data/docs/templates.md +213 -0
  76. data/docs/testing.md +155 -0
  77. data/docs/theming-hooks.md +281 -0
  78. data/docs/tutorials/the-streamweaver-way.md +882 -0
  79. data/docs/university/capability-inventory.md +137 -0
  80. data/docs/university/dependency-survey.md +114 -0
  81. data/docs/university/design-spec.md +217 -0
  82. data/docs/university/mockups/code-block-doc-theme-dark.png +0 -0
  83. data/docs/university/mockups/code-block-doc-theme-light.png +0 -0
  84. data/docs/university/mockups/course-list-dark.png +0 -0
  85. data/docs/university/mockups/course-list-light.png +0 -0
  86. data/docs/university/mockups/course_canvas_mockup.rb +741 -0
  87. data/docs/university/mockups/step-screen-dark.png +0 -0
  88. data/docs/university/mockups/step-screen-light.png +0 -0
  89. data/docs/university/roadmap.md +72 -0
  90. data/docs/university/send-to-coworker.md +77 -0
  91. data/docs/university/worker-session-mining.md +109 -0
  92. data/docs/visual/browser-rendering-spike.md +173 -0
  93. data/docs/visual/builder-visual-plan-analysis.md +378 -0
  94. data/docs/visual/sw-plan-format-exploration.md +511 -0
  95. data/docs/visual/sw-plan-rendering-deep-dive.md +453 -0
  96. data/docs/visual-skills/PROGRESS.md +60 -0
  97. data/docs/visual-skills/SESSION-CONTEXT.md +207 -0
  98. data/docs/visual-skills/analysis/components.md +317 -0
  99. data/docs/visual-skills/analysis/overlap.md +360 -0
  100. data/docs/visual-skills/analysis/pi-design-deck.md +1102 -0
  101. data/docs/visual-skills/analysis/streamweaver-inventory.md +93 -0
  102. data/docs/visual-skills/analysis/unified-specs.feature +1003 -0
  103. data/docs/visual-skills/analysis/visual-explainer.md +964 -0
  104. data/docs/visual-skills/blog/blog-series-outline.md +39 -0
  105. data/docs/visual-skills/blog/token-efficiency.md +73 -0
  106. data/docs/visual-skills/design/architecture.md +1725 -0
  107. data/docs/visual-skills/design/codex-review.md +340 -0
  108. data/docs/visual-skills/design/dhh-review.md +209 -0
  109. data/docs/visual-skills/design/evolution.md +100 -0
  110. data/docs/visual-skills/design/gemini-review.md +55 -0
  111. data/docs/visual-skills/design/review-synthesis.md +149 -0
  112. data/docs/visual-skills/implementation/STATE.md +22 -0
  113. data/docs/visual-skills/implementation/plan.md +347 -0
  114. data/docs/visual-skills/implementation/spike-findings.md +237 -0
  115. data/docs/visual-skills/implementation/tasks.md +702 -0
  116. data/docs/visual-skills/lessons-learned/process.md +145 -0
  117. data/examples/README.md +96 -0
  118. data/examples/advanced/all_components.rb +177 -0
  119. data/examples/advanced/examples_browser.rb +394 -0
  120. data/examples/advanced/teachables_browser.rb +261 -0
  121. data/examples/advanced/theme_tweaker.rb +366 -0
  122. data/examples/advanced/tutorial.rb +1528 -0
  123. data/examples/agentic/agentic_form.rb +30 -0
  124. data/examples/agentic/agentic_form_autoclose.rb +30 -0
  125. data/examples/agentic/cultivation_tracker.rb +69 -0
  126. data/examples/basic/hello_world.rb +22 -0
  127. data/examples/basic/opal_tabs_table.rb +44 -0
  128. data/examples/basic/todo_list.rb +39 -0
  129. data/examples/button_loading_test.rb +36 -0
  130. data/examples/canvas/mermaid_canvas_demo.sh +151 -0
  131. data/examples/charts/bar_chart_demo.rb +57 -0
  132. data/examples/charts/line_chart_demo.rb +73 -0
  133. data/examples/charts/pie_area_demo.rb +87 -0
  134. data/examples/charts/stacked_bar_chart_demo.rb +69 -0
  135. data/examples/claude_code/README.md +85 -0
  136. data/examples/claude_code/codebreaker/.claude/commands/infiltrate.md +632 -0
  137. data/examples/claude_code/codebreaker/.claude/settings.json +11 -0
  138. data/examples/claude_code/codebreaker/.claude/settings.local.json +17 -0
  139. data/examples/claude_code/codebreaker/.claude/skills/infiltrate.md +0 -0
  140. data/examples/claude_code/codebreaker/README.md +213 -0
  141. data/examples/claude_code/tutorial/.claude/commands/learn.md +453 -0
  142. data/examples/claude_code/tutorial/README.md +102 -0
  143. data/examples/claude_code/verification_flow/.claude/commands/verify.md +171 -0
  144. data/examples/claude_code/verification_flow/README.md +126 -0
  145. data/examples/components/annotated_code_demo.rb +54 -0
  146. data/examples/components/callout_demo.rb +33 -0
  147. data/examples/components/checkbox_group_demo.rb +46 -0
  148. data/examples/components/design_review.css +468 -0
  149. data/examples/components/design_review_demo.rb +44 -0
  150. data/examples/components/design_review_dsl.rb +300 -0
  151. data/examples/components/diff_block_demo.rb +112 -0
  152. data/examples/components/events_demo.rb +184 -0
  153. data/examples/components/form_demo.rb +62 -0
  154. data/examples/components/lesson_demo.rb +53 -0
  155. data/examples/components/markdown_demo.rb +110 -0
  156. data/examples/components/mermaid_demo.rb +166 -0
  157. data/examples/components/pareto_set.rb +64 -0
  158. data/examples/components/prd_demo.rb +21 -0
  159. data/examples/components/prd_dsl.rb +385 -0
  160. data/examples/components/quiz_demo.rb +47 -0
  161. data/examples/components/run_viewer_demo.rb +100 -0
  162. data/examples/components/score_and_collapsible_demo.rb +82 -0
  163. data/examples/components/select_stale_value_smoke_test.rb +39 -0
  164. data/examples/components/table_demo.rb +247 -0
  165. data/examples/components/timer_with_state_demo.rb +58 -0
  166. data/examples/components/todo_due_dates.rb +63 -0
  167. data/examples/components/uat_gaps_demo.rb +112 -0
  168. data/examples/dashboard/feed_simulator.rb +60 -0
  169. data/examples/dashboard/live_dashboard.rb +80 -0
  170. data/examples/dashboard_components.rb +108 -0
  171. data/examples/deferred_fragments_demo.rb +62 -0
  172. data/examples/generate_more_spike/README.md +63 -0
  173. data/examples/generate_more_spike/app.rb +441 -0
  174. data/examples/git_health.sh +401 -0
  175. data/examples/layout/layout_components_demo.rb +174 -0
  176. data/examples/layout/modal_demo.rb +215 -0
  177. data/examples/layout/navigation_demo.rb +227 -0
  178. data/examples/layout/route_tabs_demo.rb +52 -0
  179. data/examples/layout/routing_demo.rb +52 -0
  180. data/examples/layout/scroll_box_demo.rb +154 -0
  181. data/examples/lazy_fragments_demo.rb +151 -0
  182. data/examples/my_todos/README.md +29 -0
  183. data/examples/my_todos/my_todos.rb +312 -0
  184. data/examples/my_todos/store.rb +86 -0
  185. data/examples/opal/reactive_demo.rb +62 -0
  186. data/examples/opal/scenarios/s1_counter.rb +15 -0
  187. data/examples/opal/scenarios/s2_search_filter.rb +17 -0
  188. data/examples/opal/scenarios/s3_sibling_tabs.rb +25 -0
  189. data/examples/opal/scenarios/s4_shopping_cart.rb +34 -0
  190. data/examples/opal/scenarios/s5_watch.rb +39 -0
  191. data/examples/opal/scenarios/s6_on_start.rb +20 -0
  192. data/examples/opal/scenarios/s7_wizard.rb +33 -0
  193. data/examples/opal/scenarios/s8_loan_calculator.rb +21 -0
  194. data/examples/opal/scenarios/s9_dashboard.rb +27 -0
  195. data/examples/operations_dashboard_demo.rb +167 -0
  196. data/examples/panel_demo.sh +447 -0
  197. data/examples/parity/assets/tyrion_slice.css +86 -0
  198. data/examples/parity/rivet_people_slice.rb +182 -0
  199. data/examples/parity/tyrion_components.css +777 -0
  200. data/examples/parity/tyrion_warroom_components.rb +414 -0
  201. data/examples/parity/tyrion_warroom_slice.rb +549 -0
  202. data/examples/puma_dev/README.md +124 -0
  203. data/examples/puma_dev/config.ru +34 -0
  204. data/examples/puma_dev/standalone_app.rb +34 -0
  205. data/examples/scaffolding/blog.rb +49 -0
  206. data/examples/scaffolding/utf_lite.rb +105 -0
  207. data/examples/styling/feedback_demo.rb +229 -0
  208. data/examples/styling/style_showcase.rb +172 -0
  209. data/examples/styling/theme_demo.rb +508 -0
  210. data/examples/timer_health_checker.rb +111 -0
  211. data/examples/timer_showcase.rb +131 -0
  212. data/examples/tutorials/render_markdown.rb +112 -0
  213. data/examples/tutorials/streamweaver_way_tutorial.rb +44 -0
  214. data/examples/tutorials/tutorial_content.rb +1082 -0
  215. data/examples/visual_skills/design_deck_demo.rb +314 -0
  216. data/examples/visual_skills/explainer_demo.rb +499 -0
  217. data/exe/streamweaver +9 -0
  218. data/gsd/ROADMAP-1.0.md +167 -0
  219. data/gsd/STATE.md +13 -0
  220. data/gsd/research/market-positioning-research.md +139 -0
  221. data/gsd/research/production-patterns-research.md +270 -0
  222. data/gsd/research/repo-audit-1.0.md +274 -0
  223. data/lib/stream_weaver/action_token.rb +58 -0
  224. data/lib/stream_weaver/adapter/alpinejs.rb +8249 -0
  225. data/lib/stream_weaver/adapter/base.rb +591 -0
  226. data/lib/stream_weaver/adapter/opal.rb +334 -0
  227. data/lib/stream_weaver/adapter/static.rb +1118 -0
  228. data/lib/stream_weaver/admin.rb +176 -0
  229. data/lib/stream_weaver/app.rb +1821 -0
  230. data/lib/stream_weaver/assets/js/sw-copy.js +50 -0
  231. data/lib/stream_weaver/assets/js/sw-heredoc-rewrite.js +68 -0
  232. data/lib/stream_weaver/assets/js/sw-keyboard.js +165 -0
  233. data/lib/stream_weaver/assets/js/sw-mermaid-zoom.js +621 -0
  234. data/lib/stream_weaver/assets/js/sw-route-tabs.js +53 -0
  235. data/lib/stream_weaver/assets/js/sw-sidebar-toc.js +116 -0
  236. data/lib/stream_weaver/assets/js/sw-slide-nav.js +103 -0
  237. data/lib/stream_weaver/canvas/bridge.rb +239 -0
  238. data/lib/stream_weaver/canvas/bridge_server.rb +646 -0
  239. data/lib/stream_weaver/canvas/client.rb +298 -0
  240. data/lib/stream_weaver/canvas/doc_roots.rb +233 -0
  241. data/lib/stream_weaver/canvas/doc_store.rb +252 -0
  242. data/lib/stream_weaver/canvas/gist_publisher.rb +264 -0
  243. data/lib/stream_weaver/canvas/gist_save_handler.rb +89 -0
  244. data/lib/stream_weaver/canvas/gist_store.rb +135 -0
  245. data/lib/stream_weaver/canvas/helpers.rb +109 -0
  246. data/lib/stream_weaver/canvas/history.rb +90 -0
  247. data/lib/stream_weaver/canvas/protocol.rb +86 -0
  248. data/lib/stream_weaver/canvas/reader.rb +853 -0
  249. data/lib/stream_weaver/canvas/save_doc_widget.rb +457 -0
  250. data/lib/stream_weaver/canvas/scroll_top_hint.rb +21 -0
  251. data/lib/stream_weaver/canvas/session.rb +132 -0
  252. data/lib/stream_weaver/cli.rb +3235 -0
  253. data/lib/stream_weaver/component_assets.rb +70 -0
  254. data/lib/stream_weaver/component_registry.rb +67 -0
  255. data/lib/stream_weaver/component_renderer.rb +48 -0
  256. data/lib/stream_weaver/components/annotated_code.rb +53 -0
  257. data/lib/stream_weaver/components/api_endpoint.rb +42 -0
  258. data/lib/stream_weaver/components/callout.rb +59 -0
  259. data/lib/stream_weaver/components/chart.rb +84 -0
  260. data/lib/stream_weaver/components/code_block.rb +77 -0
  261. data/lib/stream_weaver/components/comparison.rb +39 -0
  262. data/lib/stream_weaver/components/decision.rb +38 -0
  263. data/lib/stream_weaver/components/deck/close_overlay.rb +84 -0
  264. data/lib/stream_weaver/components/deck/confirmation_bar.rb +60 -0
  265. data/lib/stream_weaver/components/deck/deck_option.rb +61 -0
  266. data/lib/stream_weaver/components/deck/deck_slide.rb +77 -0
  267. data/lib/stream_weaver/components/deck/deck_state.rb +469 -0
  268. data/lib/stream_weaver/components/deck/deck_summary.rb +73 -0
  269. data/lib/stream_weaver/components/deck/design_deck.rb +60 -0
  270. data/lib/stream_weaver/components/deck/generate_more_controls.rb +84 -0
  271. data/lib/stream_weaver/components/deck/model_selector.rb +84 -0
  272. data/lib/stream_weaver/components/deck/skeleton_placeholder.rb +36 -0
  273. data/lib/stream_weaver/components/diff_block.rb +125 -0
  274. data/lib/stream_weaver/components/doc_header.rb +57 -0
  275. data/lib/stream_weaver/components/image_block.rb +70 -0
  276. data/lib/stream_weaver/components/implementation_map.rb +33 -0
  277. data/lib/stream_weaver/components/keyboard_shortcuts.rb +94 -0
  278. data/lib/stream_weaver/components/kpi_dashboard.rb +78 -0
  279. data/lib/stream_weaver/components/mermaid.rb +79 -0
  280. data/lib/stream_weaver/components/pipeline.rb +63 -0
  281. data/lib/stream_weaver/components/sidebar_toc.rb +46 -0
  282. data/lib/stream_weaver/components/slide_container.rb +141 -0
  283. data/lib/stream_weaver/components/timeline_event.rb +58 -0
  284. data/lib/stream_weaver/components/wireframe.rb +29 -0
  285. data/lib/stream_weaver/components/wireframe_block.rb +34 -0
  286. data/lib/stream_weaver/components.rb +2711 -0
  287. data/lib/stream_weaver/css.rb +230 -0
  288. data/lib/stream_weaver/dev_fallback_overlay.rb +61 -0
  289. data/lib/stream_weaver/display_dsl.rb +1004 -0
  290. data/lib/stream_weaver/export/html_exporter.rb +478 -0
  291. data/lib/stream_weaver/feed.rb +34 -0
  292. data/lib/stream_weaver/feed_builder.rb +31 -0
  293. data/lib/stream_weaver/fonts.rb +33 -0
  294. data/lib/stream_weaver/interaction_runner.rb +487 -0
  295. data/lib/stream_weaver/iterm.rb +460 -0
  296. data/lib/stream_weaver/layout_registry.rb +92 -0
  297. data/lib/stream_weaver/opal/bridge.rb +52 -0
  298. data/lib/stream_weaver/opal/builder.rb +193 -0
  299. data/lib/stream_weaver/opal/env.rb +29 -0
  300. data/lib/stream_weaver/opal/reactive_state.rb +75 -0
  301. data/lib/stream_weaver/opal/regexp_anchor_patch.rb +62 -0
  302. data/lib/stream_weaver/opal/renderer.rb +77 -0
  303. data/lib/stream_weaver/opal/runtime.rb +250 -0
  304. data/lib/stream_weaver/opal/shell.rb +145 -0
  305. data/lib/stream_weaver/opal/string_bridge.rb +50 -0
  306. data/lib/stream_weaver/opal/stubs/diff.min.js +1 -0
  307. data/lib/stream_weaver/opal/stubs/digest.rb +15 -0
  308. data/lib/stream_weaver/opal/stubs/marked.umd.js +79 -0
  309. data/lib/stream_weaver/opal/stubs/md5.rb +3 -0
  310. data/lib/stream_weaver/opal/stubs/morphdom.min.js +775 -0
  311. data/lib/stream_weaver/opal/stubs/prism-tomorrow.min.css +1 -0
  312. data/lib/stream_weaver/opal/stubs/prism.min.js +1967 -0
  313. data/lib/stream_weaver/opal_entry.rb +136 -0
  314. data/lib/stream_weaver/org/inline.rb +95 -0
  315. data/lib/stream_weaver/org/reader.rb +561 -0
  316. data/lib/stream_weaver/org/recording_context.rb +86 -0
  317. data/lib/stream_weaver/org/source_splitter.rb +63 -0
  318. data/lib/stream_weaver/org/writer.rb +300 -0
  319. data/lib/stream_weaver/page_shell.rb +530 -0
  320. data/lib/stream_weaver/portfile.rb +79 -0
  321. data/lib/stream_weaver/pushable.rb +42 -0
  322. data/lib/stream_weaver/resource/default_views.rb +111 -0
  323. data/lib/stream_weaver/resource/field_input.rb +25 -0
  324. data/lib/stream_weaver/resource/state_keys.rb +16 -0
  325. data/lib/stream_weaver/resource/store.rb +18 -0
  326. data/lib/stream_weaver/resource.rb +104 -0
  327. data/lib/stream_weaver/server.rb +1471 -0
  328. data/lib/stream_weaver/service.rb +1240 -0
  329. data/lib/stream_weaver/service_client.rb +104 -0
  330. data/lib/stream_weaver/session_store.rb +186 -0
  331. data/lib/stream_weaver/skills/streamweaver-canvas-safe/SKILL.md +66 -0
  332. data/lib/stream_weaver/skills/streamweaver-canvas-safe/examples/canvas-safe-showcase.rb +98 -0
  333. data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/actions-and-buttons.md +47 -0
  334. data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/charts-and-diagrams.md +38 -0
  335. data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/deck.md +32 -0
  336. data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/inputs-and-forms.md +65 -0
  337. data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/tabs-and-navigation.md +53 -0
  338. data/lib/stream_weaver/skills/streamweaver-doc-builder/SKILL.md +298 -0
  339. data/lib/stream_weaver/skills/streamweaver-visual-companion/SKILL.md +130 -0
  340. data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/design-review-example.css +468 -0
  341. data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/design-review-example.rb +39 -0
  342. data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/design-review-example_dsl.rb +300 -0
  343. data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/doc-parity-example.rb +24 -0
  344. data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/doc-parity-example_dsl.rb +385 -0
  345. data/lib/stream_weaver/skills/streamweaver-visual-companion/references/checkpoints-and-forms.md +28 -0
  346. data/lib/stream_weaver/skills/streamweaver-visual-companion/references/cleanup-and-panel.md +44 -0
  347. data/lib/stream_weaver/skills/streamweaver-visual-companion/references/example-gallery.md +8 -0
  348. data/lib/stream_weaver/skills/streamweaver-visual-companion/references/persistence.md +58 -0
  349. data/lib/stream_weaver/skills/streamweaver-way/SKILL.md +396 -0
  350. data/lib/stream_weaver/skills/visual-plan/SKILL.md +201 -0
  351. data/lib/stream_weaver/skills/visual-recap/SKILL.md +244 -0
  352. data/lib/stream_weaver/streamer.rb +63 -0
  353. data/lib/stream_weaver/templates/choices.rb +149 -0
  354. data/lib/stream_weaver/templates/code.rb +157 -0
  355. data/lib/stream_weaver/templates/confirm.rb +110 -0
  356. data/lib/stream_weaver/templates/diff.rb +195 -0
  357. data/lib/stream_weaver/templates/info.rb +142 -0
  358. data/lib/stream_weaver/templates/table.rb +169 -0
  359. data/lib/stream_weaver/templates/wizard.rb +271 -0
  360. data/lib/stream_weaver/theme/auto_mode.rb +116 -0
  361. data/lib/stream_weaver/theme/presets.rb +555 -0
  362. data/lib/stream_weaver/theme.rb +639 -0
  363. data/lib/stream_weaver/university/canvas.rb +924 -0
  364. data/lib/stream_weaver/university/course.rb +699 -0
  365. data/lib/stream_weaver/university/demos/counter.rb +33 -0
  366. data/lib/stream_weaver/university/demos/dashboard.rb +147 -0
  367. data/lib/stream_weaver/university/demos/decision_form.rb +143 -0
  368. data/lib/stream_weaver/university/demos.rb +50 -0
  369. data/lib/stream_weaver/university/listener.rb +467 -0
  370. data/lib/stream_weaver/university/progress.rb +201 -0
  371. data/lib/stream_weaver/university/runner.rb +138 -0
  372. data/lib/stream_weaver/university/scripts/growing_doc.rb +609 -0
  373. data/lib/stream_weaver/university/scripts/growing_doc_state.rb +91 -0
  374. data/lib/stream_weaver/utils.rb +31 -0
  375. data/lib/stream_weaver/version.rb +5 -0
  376. data/lib/stream_weaver/views/canvas/reader_layout.erb +800 -0
  377. data/lib/stream_weaver/views.rb +3940 -0
  378. data/lib/stream_weaver.rb +122 -0
  379. data/llms.txt +1269 -0
  380. data/sig/stream_weaver.rbs +4 -0
  381. metadata +598 -0
@@ -0,0 +1,252 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'securerandom'
5
+ # Mutually required with doc_roots (which needs DOCS_SUBPATH/DEFAULT_ROOT for
6
+ # its scan and its global group). Safe in both load orders: Ruby marks a file
7
+ # as loaded before evaluating it, so the second require is a no-op, and
8
+ # neither file touches the other's constants at load time -- only inside
9
+ # method bodies.
10
+ require 'stream_weaver/canvas/doc_roots'
11
+
12
+ module StreamWeaver
13
+ module Canvas
14
+ # Tier 2 (persistent, in-repo) storage for canvas docs the user has
15
+ # explicitly chosen to keep. Sibling of Canvas::History (Tier 1, ephemeral).
16
+ #
17
+ # Resolved path priority:
18
+ # 1. ENV['STREAMWEAVER_DOC_ROOT'] (test/override hook)
19
+ # 2. <git_root>/docs/streamweaver_canvas (when invoked inside a repo)
20
+ # 3. ~/.streamweaver/canvas (no-repo fallback)
21
+ #
22
+ # Git-root detection walks the filesystem looking for a .git entry on the
23
+ # current working directory or any ancestor. We deliberately do NOT shell
24
+ # out to `git`: filesystem walking is faster, has no PATH/install
25
+ # dependency, and is easy to stub in specs. A .git that is either a
26
+ # directory (normal repo) or a file (worktree/submodule pointer) counts
27
+ # as a hit -- File.exist? covers both.
28
+ #
29
+ # Doc names are validated against an allowlist (alnum + . _ -, must start
30
+ # with alnum) and an explicit '..'-substring + null-byte check. Bad names
31
+ # raise ArgumentError -- raising rather than sanitizing keeps the contract
32
+ # explicit and avoids silently writing to an unexpected file. The
33
+ # extension is forced and idempotent: .rb and .org are both valid
34
+ # (save("foo") and save("foo.rb") both write to <path>/foo.rb; likewise
35
+ # for .org), defaulting to .rb when neither is present. Extra dots
36
+ # within the name are allowed (auth-flow.v2 -> auth-flow.v2.rb) since
37
+ # the allowlist already permits '.' and descriptive multi-segment names
38
+ # are useful.
39
+ #
40
+ # No cleanup method: Tier 2 is permanent. Git is the cleanup mechanism.
41
+ module DocStore
42
+ DEFAULT_ROOT = File.expand_path('~/.streamweaver/canvas')
43
+ DOCS_SUBPATH = File.join('docs', 'streamweaver_canvas')
44
+ VALID_NAME = /\A[A-Za-z0-9][A-Za-z0-9._-]*\z/
45
+
46
+ # Marks a file as a StreamWeaver doc body.
47
+ #
48
+ # A saved doc is a bare DSL body -- no require, no app wrapper -- so
49
+ # nothing about the file announces what it is. Tooling that finds one out
50
+ # of context (a renderer looking at a GitHub blob, an editor plugin, a
51
+ # human) has no reliable way to tell it apart from ordinary Ruby.
52
+ # Guessing from content does not work either: substantial docs use a dozen
53
+ # distinct DSL calls, but a thin one may be almost entirely `md`.
54
+ #
55
+ # A comment costs nothing at eval time and travels with the file wherever
56
+ # it goes, independent of path or extension.
57
+ STAMP = '# streamweaver-doc: v1'
58
+
59
+ # Recognizes the stamp anywhere in a leading comment block, so it still
60
+ # matches if a magic comment (frozen_string_literal) or a title comment
61
+ # sits above it. The version is captured but not pinned -- a v2 doc should
62
+ # still be recognizable as a doc.
63
+ STAMP_RE = /^#\s*streamweaver-doc:\s*v(\d+)\s*$/
64
+
65
+ # How far into a file to look. Deep enough for a comment header, shallow
66
+ # enough that a stray match in prose does not count.
67
+ STAMP_SCAN_LINES = 10
68
+
69
+ module_function
70
+
71
+ # Resolves the docs directory. Read fresh on every call so an ENV
72
+ # override flips behavior mid-process (specs rely on this).
73
+ def path
74
+ env = ENV['STREAMWEAVER_DOC_ROOT']
75
+ return env if env && !env.empty?
76
+
77
+ if (root = git_root)
78
+ File.join(root, DOCS_SUBPATH)
79
+ else
80
+ DEFAULT_ROOT
81
+ end
82
+ end
83
+
84
+ # Walks up from `start` looking for a .git entry. Returns the absolute
85
+ # path of the directory that contains it, or nil at the filesystem
86
+ # root. Treats .git as a hit whether it is a directory or a file
87
+ # (worktree/submodule).
88
+ def git_root(start = Dir.pwd)
89
+ dir = File.expand_path(start)
90
+ loop do
91
+ return dir if File.exist?(File.join(dir, '.git'))
92
+
93
+ parent = File.dirname(dir)
94
+ return nil if parent == dir
95
+
96
+ dir = parent
97
+ end
98
+ end
99
+
100
+ # Writes `dsl` to <path>/<normalized_name>.rb and returns the absolute
101
+ # path. Creates the docs directory if missing. Overwrites any existing
102
+ # file with the same name (Tier 2 docs are user-managed; collisions
103
+ # mean the user is intentionally updating).
104
+ #
105
+ # scope/source_dir (stream_weaver-j3b3): a caller with an explicit
106
+ # destination in mind (the Save-as-doc toggle) passes both. `scope:
107
+ # :global` always writes to DEFAULT_ROOT. `scope: :repo` (the default)
108
+ # writes under `source_dir` when given; when `source_dir` is nil (no
109
+ # caller preference, e.g. a caller with no repo context to offer) it
110
+ # falls back to the existing auto-detected `path` -- unchanged
111
+ # behavior for every pre-existing caller.
112
+ #
113
+ # Only .rb output gets the `# streamweaver-doc: v1` stamp -- .org output
114
+ # already self-identifies via its own `#+STREAMWEAVER_DSL: 1` header
115
+ # keyword (org-doc-format-design.md), which must be the file's first
116
+ # line unconditionally. Prepending the .rb-style stamp in front of it
117
+ # would violate that and, being a bare `#` line rather than a `#+`
118
+ # keyword, wouldn't even be recognized by org-ruby.
119
+ #
120
+ # The write is atomic: content goes to a temp file in the SAME directory
121
+ # (so the rename stays on one filesystem, where POSIX guarantees it is
122
+ # atomic) and is then renamed over the target. A plain File.write
123
+ # truncates first, so a concurrent reader -- canvas-read's docs scan on
124
+ # every render, or another process's GET -- can observe an empty or
125
+ # half-written file. The temp name is dotted and .tmp-suffixed so it
126
+ # matches neither the *.rb nor the *.org globs even in the instant it
127
+ # exists, and it is removed if the write or rename fails so a failure
128
+ # never litters the docs directory.
129
+ def save(name, dsl, scope: :repo, source_dir: nil)
130
+ filename = normalize_name(name)
131
+ dir = target_dir(scope, source_dir)
132
+ FileUtils.mkdir_p(dir)
133
+
134
+ full = File.join(dir, filename)
135
+ content = filename.end_with?('.org') ? dsl : stamp(dsl)
136
+ tmp = File.join(dir, ".#{filename}.#{Process.pid}.#{SecureRandom.hex(4)}.tmp")
137
+ begin
138
+ File.write(tmp, content)
139
+ File.rename(tmp, full)
140
+ rescue StandardError
141
+ FileUtils.rm_f(tmp)
142
+ raise
143
+ end
144
+
145
+ # Half of canvas-read's cross-repo discovery (stream_weaver-iugu):
146
+ # saving into a repo is what makes that repo's docs findable from a
147
+ # canvas-read launched anywhere else. Deliberately after the rename,
148
+ # so a failed write never registers a root that has no docs in it.
149
+ # DocRoots.record swallows its own filesystem errors -- a registry
150
+ # that can't be written must never fail the save it followed.
151
+ DocRoots.record(dir)
152
+
153
+ full
154
+ end
155
+
156
+ # Prepends `use_theme`/`use_layout` declarations so a saved doc renders
157
+ # with the theme/layout of the canvas session it came from
158
+ # (stream_weaver-csf). Saved docs are re-rendered later by canvas-read,
159
+ # which has no session to inherit from -- only the DSL text -- so the
160
+ # metadata has to live in the text itself.
161
+ #
162
+ # Shared by BOTH save routes (BridgeServer's /canvas/:name/save-doc and
163
+ # Reader's /save-doc) deliberately: duplicating the prepend logic would
164
+ # let the two Save-as-doc buttons silently write different files.
165
+ #
166
+ # Idempotent: a DSL that already declares either directive keeps its own,
167
+ # so re-saving never stacks duplicates. nil theme/layout are skipped --
168
+ # that's the Reader's promote-from-history case, where the snapshot was
169
+ # written by `canvas-push` (which never sees the bridge session's theme),
170
+ # so those docs keep rendering with canvas-read's default.
171
+ def dsl_with_metadata(dsl, theme: nil, layout: nil)
172
+ lines = []
173
+ lines << "use_theme :#{theme}" if theme && !dsl.match?(/^[ \t]*use_theme\b/)
174
+ lines << "use_layout :#{layout}" if layout && !dsl.match?(/^[ \t]*use_layout\b/)
175
+ return dsl if lines.empty?
176
+
177
+ (lines + [dsl]).join("\n")
178
+ end
179
+
180
+ # True when `source` already declares itself a StreamWeaver doc.
181
+ #
182
+ # Only the first STAMP_SCAN_LINES are considered, so a doc that happens to
183
+ # quote the stamp inside prose or a code_block further down is not
184
+ # mistaken for a stamped file.
185
+ def stamped?(source)
186
+ return false unless source.is_a?(String)
187
+
188
+ source.each_line.first(STAMP_SCAN_LINES).any? { |line| line.match?(STAMP_RE) }
189
+ end
190
+
191
+ # Returns `dsl` with the stamp on its first line, or unchanged if it is
192
+ # already stamped.
193
+ #
194
+ # Idempotent on purpose: saving over an existing doc is the normal way to
195
+ # update one, and the stamp must not accumulate. Prepending is safe even
196
+ # when the body opens with `# frozen_string_literal: true` -- Ruby honors
197
+ # a magic comment anywhere in the leading comment block, not only on line
198
+ # one.
199
+ def stamp(dsl)
200
+ text = dsl.to_s
201
+ return text if stamped?(text)
202
+ # Defense in depth, not currently reachable: `save`'s own
203
+ # filename.end_with?('.org') check is what actually protects org
204
+ # content today (`stamp` has no other caller). Kept here too so a
205
+ # future caller of `stamp` directly can't reintroduce the exact bug
206
+ # c1df5f6 fixed -- the .rb-style stamp corrupting .org's hard
207
+ # requirement that '#+STREAMWEAVER_DSL: 1' be the literal first line.
208
+ return text if text.start_with?('#+')
209
+ return "#{STAMP}\n" if text.empty?
210
+
211
+ "#{STAMP}\n#{text}"
212
+ end
213
+
214
+ # Strips a single trailing .rb or .org (case-sensitive), validates the
215
+ # resulting basename against VALID_NAME with explicit '..' and
216
+ # null-byte rejection, then re-adds whichever extension was detected
217
+ # (defaulting to .rb when neither is present). Bare ".rb"/".org"
218
+ # normalize to an empty basename and are rejected.
219
+ def normalize_name(name)
220
+ raise ArgumentError, "invalid doc name: #{name.inspect}" unless name.is_a?(String)
221
+
222
+ ext, stripped = if name.end_with?('.rb')
223
+ ['.rb', name[0...-3]]
224
+ elsif name.end_with?('.org')
225
+ ['.org', name[0...-4]]
226
+ else
227
+ ['.rb', name]
228
+ end
229
+
230
+ unless !stripped.empty? &&
231
+ !stripped.include?('..') &&
232
+ !stripped.include?("\0") &&
233
+ stripped.match?(VALID_NAME)
234
+ raise ArgumentError, "invalid doc name: #{name.inspect}"
235
+ end
236
+
237
+ "#{stripped}#{ext}"
238
+ end
239
+ private_class_method :normalize_name
240
+
241
+ # Resolves the destination directory for `save`'s scope/source_dir
242
+ # arguments. See `save`'s comment for the semantics.
243
+ def target_dir(scope, source_dir)
244
+ return DEFAULT_ROOT if scope == :global
245
+ return File.join(source_dir, DOCS_SUBPATH) if source_dir
246
+
247
+ path
248
+ end
249
+ private_class_method :target_dir
250
+ end
251
+ end
252
+ end
@@ -0,0 +1,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'open3'
5
+ require 'timeout'
6
+ require_relative 'doc_store'
7
+ require_relative '../org/writer'
8
+
9
+ module StreamWeaver
10
+ module Canvas
11
+ # Publishes a canvas doc to a secret GitHub gist via the `gh` CLI.
12
+ #
13
+ # This is the only place in StreamWeaver that shells out to `gh` and the
14
+ # only place that writes to an external, user-visible URL -- a coworker
15
+ # opens the link this returns, so a bug here ships wrong content to a
16
+ # real person rather than to a file the author can quietly fix.
17
+ #
18
+ # Both files go up in ONE api call: the rendered `.org` (what GitHub
19
+ # renders as a formatted doc -- the reason for sharing at all) and the
20
+ # re-runnable `.rb` DSL source (the source of truth). One save is
21
+ # therefore exactly one gist revision, which is what makes
22
+ # `<gist_url>/revisions` a meaningful timeline instead of a stutter of
23
+ # half-updates. `gh gist create`/`gh gist edit` were rejected for this:
24
+ # neither puts two files into a single call cleanly across the
25
+ # create-vs-update split, and neither gives direct control over the JSON
26
+ # payload.
27
+ #
28
+ # Visibility: secret, always. `public` is never sent -- not on create
29
+ # (gh's own API default is secret, so omitting the key is the same as
30
+ # asking for secret, with one less thing to get backwards) and never on
31
+ # update, where GitHub silently ignores it because gist visibility is
32
+ # immutable after creation. Sending a key that is either redundant or
33
+ # ignored is a landmine for whoever reads this next.
34
+ module GistPublisher
35
+ # Seconds to wait on one `gh api` round trip. The caller is a Sinatra
36
+ # request thread serving the canvas's Save-as-doc dialog; without this,
37
+ # a stalled TLS handshake against api.github.com hangs that thread with
38
+ # the user staring at a spinner. 20s is generous for a two-file POST and
39
+ # still short enough to fail visibly.
40
+ #
41
+ # Timeout.timeout unblocks the CALLER, not the child: a `gh` that is
42
+ # truly wedged may outlive the request by a few seconds before its own
43
+ # network layer gives up. That is acceptable -- it holds no lock and
44
+ # writes nothing of ours -- and is the reason this is a timeout rather
45
+ # than a process kill.
46
+ TIMEOUT_SECONDS = 20
47
+
48
+ # `existing_id` reaches us from the gist store and is interpolated into
49
+ # the API path (`/gists/<id>`), where a `..` segment would silently
50
+ # retarget the request at a different endpoint. argv is already
51
+ # shell-safe, so this is not about shell injection -- it is about not
52
+ # letting a corrupted store entry aim a PATCH somewhere unintended.
53
+ #
54
+ # Alphanumeric rather than strictly hex: today's gist ids are hex, but
55
+ # the id is GitHub's to define and the only property this actually needs
56
+ # is "contains no path syntax".
57
+ VALID_GIST_ID = /\A[A-Za-z0-9]+\z/
58
+
59
+ # Stderr shapes that mean "your credentials are the problem", as
60
+ # opposed to any other API failure. gh's own wording here is
61
+ # famously unhelpful out of context ("Bad credentials (HTTP 401)"
62
+ # tells a canvas user nothing), so these get rewritten into copy that
63
+ # names the actual fix.
64
+ #
65
+ # Every alternative is a specific known gh/GitHub phrase, never a bare
66
+ # keyword. That is deliberate: rewriting an unrelated failure into
67
+ # "run `gh auth login`" would hand the user a confident wrong diagnosis,
68
+ # which is worse than passing gh's own message through untouched. Two
69
+ # consequences of that rule worth naming: a bare HTTP 403 is NOT matched
70
+ # (rate limiting and org policy return it too), and the word "scope"
71
+ # only counts next to language about it being missing or required.
72
+ AUTH_FAILURE_RE = /
73
+ HTTP\s+401
74
+ | bad\s+credentials
75
+ | gh\s+auth\s+login
76
+ | GH_TOKEN
77
+ | not\s+accessible\s+by\s+personal\s+access\s+token
78
+ | (?:required|missing|insufficient|granted|needs)[^\n]{0,60}\bscopes?\b
79
+ | \bscopes?\b[^\n]{0,60}(?:required|missing|insufficient)
80
+ | SAML
81
+ /xi
82
+
83
+ # A deleted-upstream gist. Only meaningful on an update -- see #publish.
84
+ NOT_FOUND_RE = /HTTP\s+404|\bnot\s+found\b/i
85
+
86
+ AUTH_HELP = 'GitHub CLI could not authenticate. Run `gh auth login` and make sure the ' \
87
+ 'token has the `gist` scope.'
88
+
89
+ module_function
90
+
91
+ # Renders `dsl` to both formats and publishes them as one gist.
92
+ #
93
+ # Returns a plain hash rather than raising, because every caller is an
94
+ # HTTP handler that has to turn the outcome into a JSON body either way:
95
+ #
96
+ # success: { ok: true, id:, url:, revisions:, action: "create"|"update",
97
+ # coverage: {...} }
98
+ # failure: { ok: false, error: "<message fit to show a user>" }
99
+ #
100
+ # On the stale-id recovery path the success hash also carries
101
+ # `forget_stale_id: "<old id>"` -- the caller's cue to drop that id from
102
+ # GistStore so the next save updates the new gist instead of chasing the
103
+ # deleted one again.
104
+ #
105
+ # ArgumentError from an invalid doc name is deliberately NOT caught: it
106
+ # is a 422 (the user typed a bad name), not a 502 (the publish failed),
107
+ # and the existing save-doc handlers already map ArgumentError that way.
108
+ # Swallowing it here into `ok: false` would flatten that distinction.
109
+ def publish(name:, dsl:, theme: nil, layout: nil, existing_id: nil)
110
+ base = base_name(name)
111
+
112
+ writer = StreamWeaver::Org::Writer.new(dsl)
113
+ org_text = writer.call
114
+ coverage = writer.coverage
115
+ rb_text = DocStore.dsl_with_metadata(dsl, theme: theme, layout: layout)
116
+
117
+ payload = build_payload(base, org_text, rb_text)
118
+
119
+ if existing_id
120
+ unless existing_id.to_s.match?(VALID_GIST_ID)
121
+ return { ok: false, error: "invalid gist id: #{existing_id.inspect}" }
122
+ end
123
+
124
+ out, err, status = gh_api('PATCH', "/gists/#{existing_id}", payload)
125
+ return success(out, 'update', coverage) if status.success?
126
+
127
+ # A 404 here means the gist was deleted on github.com since we last
128
+ # recorded it. Publishing is still the thing the user asked for, so
129
+ # mint a fresh gist rather than failing with a message about an id
130
+ # they never saw and cannot act on. Only ever done for an update: a
131
+ # 404 from a create is a real API problem, and retrying it would
132
+ # just double the failure.
133
+ return gh_error(err, status) unless err.to_s.match?(NOT_FOUND_RE)
134
+
135
+ result = create(payload, coverage)
136
+ return result unless result[:ok]
137
+
138
+ return result.merge(forget_stale_id: existing_id)
139
+ end
140
+
141
+ create(payload, coverage)
142
+ rescue Timeout::Error
143
+ { ok: false, error: "gh timed out after #{TIMEOUT_SECONDS}s talking to GitHub" }
144
+ end
145
+
146
+ # Cheap presence check: is the `gh` binary on PATH at all?
147
+ #
148
+ # Deliberately NOT an auth check. `gh auth status` costs a network round
149
+ # trip, and this is called on every canvas render to decide whether the
150
+ # Gist radio is enabled -- putting a network call there would make the
151
+ # dialog's responsiveness depend on GitHub being up. Auth problems
152
+ # surface at publish time instead, where #publish already rewrites them
153
+ # into actionable copy (see AUTH_HELP).
154
+ #
155
+ # Memoized: gh does not appear or vanish mid-process, and the widget
156
+ # re-renders often enough that respawning a process per render would be
157
+ # visible.
158
+ def gh_available?
159
+ return @gh_available unless @gh_available.nil?
160
+
161
+ # system returns nil (not false) when the binary is missing, and the
162
+ # caller renders this straight into a JSON/HTML attribute -- coerce so
163
+ # the answer is always a real boolean.
164
+ @gh_available = system('gh', '--version', out: File::NULL, err: File::NULL) ? true : false
165
+ end
166
+
167
+ def create(payload, coverage)
168
+ out, err, status = gh_api('POST', '/gists', payload)
169
+ return gh_error(err, status) unless status.success?
170
+
171
+ success(out, 'create', coverage)
172
+ end
173
+ private_class_method :create
174
+
175
+ # One `gh api` round trip. Array argv, never a shell string: the doc
176
+ # name and gist id both reach this method from user input, and a shell
177
+ # string is the one way to turn either into command execution.
178
+ # `--input -` puts the JSON on stdin so no part of the payload ever
179
+ # becomes an argument.
180
+ def gh_api(verb, path, payload)
181
+ Timeout.timeout(TIMEOUT_SECONDS) do
182
+ Open3.capture3('gh', 'api', '-X', verb, path, '--input', '-', stdin_data: JSON.generate(payload))
183
+ end
184
+ end
185
+ private_class_method :gh_api
186
+
187
+ # Note the absence of a `public` key -- see the module comment. Both the
188
+ # create and the update send this exact structure, which is what keeps
189
+ # the two paths from drifting into sending different content.
190
+ def build_payload(base, org_text, rb_text)
191
+ {
192
+ description: description_for(base, org_text),
193
+ files: {
194
+ "#{base}.org" => { content: org_text },
195
+ "#{base}.rb" => { content: rb_text }
196
+ }
197
+ }
198
+ end
199
+ private_class_method :build_payload
200
+
201
+ # The gist description is the line a coworker reads in a list of gists,
202
+ # so the doc's own title beats the filename when there is one.
203
+ # Org::Writer emits `#+TITLE:` only when the DSL declares a doc_header.
204
+ def description_for(base, org_text)
205
+ title = org_text[/^\#\+TITLE:\s*(.+)$/, 1]&.strip
206
+ title && !title.empty? ? title : base
207
+ end
208
+ private_class_method :description_for
209
+
210
+ def success(stdout, action, coverage)
211
+ body = JSON.parse(stdout.to_s)
212
+ # A zero-exit gh whose stdout is valid JSON but not a gist object
213
+ # (`null`, a bare array) would otherwise NoMethodError on body['id']
214
+ # and reach the Sinatra handler as a 500 stack trace. Nothing in gh
215
+ # is known to do this -- the guard exists so an unexpected response
216
+ # degrades to the same readable failure as an unparseable one.
217
+ return { ok: false, error: "unexpected gh response: #{stdout.to_s[0, 200]}" } unless body.is_a?(Hash)
218
+
219
+ {
220
+ ok: true,
221
+ id: body['id'],
222
+ url: body['html_url'],
223
+ # Every gist is a git repo and `history` is its commit list, so its
224
+ # length is the revision count the widget shows next to the
225
+ # /revisions link -- no extra call needed to compute it.
226
+ revisions: Array(body['history']).length,
227
+ action: action,
228
+ coverage: coverage
229
+ }
230
+ rescue JSON::ParserError => e
231
+ { ok: false, error: "could not parse gh response: #{e.message}" }
232
+ end
233
+ private_class_method :success
234
+
235
+ def gh_error(stderr, status)
236
+ message = stderr.to_s.strip
237
+ return { ok: false, error: "#{AUTH_HELP} (gh said: #{message})" } if message.match?(AUTH_FAILURE_RE)
238
+ return { ok: false, error: "gh exited #{status.exitstatus} with no output" } if message.empty?
239
+
240
+ { ok: false, error: message }
241
+ end
242
+ private_class_method :gh_error
243
+
244
+ # DocStore.normalize_name is the project's single definition of a valid
245
+ # doc name (allowlist + explicit '..' and null-byte rejection) and it is
246
+ # what the file-writing save routes already validate against. Reusing it
247
+ # keeps a name that is legal for a gist identical to one that is legal
248
+ # for a file, so the two Save-as-doc destinations can never disagree
249
+ # about what the user typed.
250
+ #
251
+ # It is `private_class_method` on DocStore (it has no external caller
252
+ # today), hence the `send`. Making it public is the better long-term
253
+ # fix, but doc_store.rb is out of this story's scope -- flagged for
254
+ # whoever next touches that file.
255
+ #
256
+ # normalize_name returns a name WITH its extension; a gist needs the
257
+ # bare stem, because the publisher appends both extensions itself.
258
+ def base_name(name)
259
+ DocStore.send(:normalize_name, name).sub(/\.(rb|org)\z/, '')
260
+ end
261
+ private_class_method :base_name
262
+ end
263
+ end
264
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'gist_store'
4
+ require_relative 'gist_publisher'
5
+
6
+ module StreamWeaver
7
+ module Canvas
8
+ # scope == 'gist' branch of a save-doc route (share-to-gist epic),
9
+ # shared between BridgeServer's live-canvas save and Reader's
10
+ # history-snapshot promotion -- the only difference between the two
11
+ # callers is whether there's a session's theme/layout to carry
12
+ # (BridgeServer has one; Reader promotes a snapshot that never saw one,
13
+ # same accepted limitation as this route's org/rb branches -- see
14
+ # reader.rb's own comment on dsl_with_metadata).
15
+ #
16
+ # `include`d into both Sinatra::Base subclasses rather than left as two
17
+ # hand-mirrored copies: it started that way (bridge-canvas-gist-endpoint,
18
+ # then reader-gist-parity), and a second copy is exactly the point past
19
+ # which "keep it duplicated" stops paying for itself -- `halt` below
20
+ # resolves to whichever including Sinatra app calls it.
21
+ module GistSaveHandler
22
+ # base_name mirrors GistPublisher's own (private) base_name derivation
23
+ # for a valid name: DocStore.normalize_name never rewrites characters,
24
+ # it only validates and forces/strips the .rb/.org extension, so
25
+ # stripping the extension here -- same regex the callers' own org
26
+ # branches use -- lands on the identical string GistPublisher records
27
+ # under. A non-String doc_name is passed through as-is (GistStore.lookup
28
+ # coerces via #to_s, so this can only ever miss, not raise) so
29
+ # GistPublisher.publish's own DocStore.normalize_name call is what
30
+ # raises the ArgumentError, exactly like the callers' org/rb branches.
31
+ def handle_gist_save(dsl, doc_name, theme: nil, layout: nil)
32
+ rescue_save_errors do
33
+ base_name = doc_name.is_a?(String) ? doc_name.sub(/\.(rb|org)\z/, '') : doc_name
34
+ existing_id = GistStore.lookup(base_name)&.dig('id')
35
+
36
+ result = GistPublisher.publish(
37
+ name: doc_name,
38
+ dsl: dsl,
39
+ theme: theme,
40
+ layout: layout,
41
+ existing_id: existing_id
42
+ )
43
+
44
+ halt 502, { ok: false, error: result[:error] }.to_json unless result[:ok]
45
+
46
+ response = {
47
+ ok: true,
48
+ gist_url: result[:url],
49
+ gist_id: result[:id],
50
+ revisions: result[:revisions],
51
+ action: result[:action],
52
+ coverage: result[:coverage]
53
+ }
54
+
55
+ # A GistStore.record failure must never fail an otherwise-successful
56
+ # publish -- the gist is already live at result[:url] regardless of
57
+ # whether we can remember it locally. Mirrors how DocStore.save
58
+ # swallows a DocRoots.record failure (doc_store.rb:151) rather than
59
+ # raising it back at the caller.
60
+ begin
61
+ GistStore.record(
62
+ base_name, id: result[:id], url: result[:url], revisions: result[:revisions]
63
+ )
64
+ rescue StandardError => e
65
+ response[:warning] = "gist saved, but recording it locally failed: #{e.message}"
66
+ end
67
+
68
+ # Stale-id recovery (see GistPublisher#publish): result[:forget_stale_id],
69
+ # when present, is the OLD gist id that 404'd on PATCH -- there is
70
+ # nothing further to clean up for it. GistStore is keyed by doc NAME,
71
+ # not gist id, and GistStore.record above already overwrote this
72
+ # doc's one entry with the freshly-minted id, so the stale id was
73
+ # never left behind under any key to forget.
74
+ response.to_json
75
+ end
76
+ end
77
+
78
+ # Shared ArgumentError->422 / StandardError->500 mapping for the
79
+ # org/rb/gist save-doc branches.
80
+ def rescue_save_errors
81
+ yield
82
+ rescue ArgumentError => e
83
+ halt 422, { ok: false, error: e.message }.to_json
84
+ rescue StandardError => e
85
+ halt 500, { ok: false, error: e.message }.to_json
86
+ end
87
+ end
88
+ end
89
+ end