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.
- checksums.yaml +7 -0
- data/.beads/.gitignore +77 -0
- data/.beads/README.md +81 -0
- data/.beads/config.yaml +54 -0
- data/.beads/metadata.json +7 -0
- data/.rspec +3 -0
- data/AGENTS.md +91 -0
- data/CHANGELOG.md +294 -0
- data/CLAUDE.md +67 -0
- data/LICENSE.txt +21 -0
- data/README.md +565 -0
- data/Rakefile +13 -0
- data/assets/streamweaver-hero.jpg +0 -0
- data/bench/baselines/ledger.rb +58 -0
- data/bench/baselines/warroom.rb +51 -0
- data/bench/fixtures/ledger.rb +76 -0
- data/bench/fixtures/warroom.rb +60 -0
- data/bench/results/6daf8b8.md +102 -0
- data/bench/results/c8d749b.md +102 -0
- data/bench/results/eb06ab9.md +102 -0
- data/bench/run.rb +151 -0
- data/bench/support.rb +84 -0
- data/copy_this/dto_run_sample_run_7631.html +220 -0
- data/docs/SERVICE_MODE.md +302 -0
- data/docs/architecture/how_streamweaver_works.md +772 -0
- data/docs/blog/2026-01-16-streamweaver-introduction.md +515 -0
- data/docs/bug-2026-08-31-canvas-bridge-socket-collision.md +83 -0
- data/docs/canvas-ipc-session-summary.md +127 -0
- data/docs/canvas-panel-workflow.md +172 -0
- data/docs/canvas-read.md +147 -0
- data/docs/canvas-roadmap.md +275 -0
- data/docs/case-studies/2026-09-03-didx-canvas-read-shelf.md +147 -0
- data/docs/claude-code-companion-skill-spec.md +567 -0
- data/docs/components_reference.md +1223 -0
- data/docs/crud-patterns.md +265 -0
- data/docs/endpoints.md +122 -0
- data/docs/for_llms.md +1 -0
- data/docs/form-patterns.md +165 -0
- data/docs/frontend-only.md +169 -0
- data/docs/html-artifact-audit.md +238 -0
- data/docs/ideas/2025-01-01-as-a-service.md +203 -0
- data/docs/ideas/2026-01-16-charm-tui-exploration.md +542 -0
- data/docs/ideas/2026-08-27-static-doc-shelf-export.md +153 -0
- data/docs/ideas/dhh-review-future-refactors.md +142 -0
- data/docs/opal-jamstack.md +47 -0
- data/docs/opal-npm-direction.md +184 -0
- data/docs/opal-spike-findings.md +194 -0
- data/docs/plans/2025-12-16-form-blocks-design.md +124 -0
- data/docs/plans/2025-12-30-examples-browser-design.md +152 -0
- data/docs/plans/2026-01-02-charts-design.md +288 -0
- data/docs/plans/2026-01-10-canvas-ipc-design.md +247 -0
- data/docs/plans/2026-01-19-cabinet-control-components.md +176 -0
- data/docs/plans/2026-01-26-gem-release-and-panel.md +140 -0
- data/docs/plans/canvas-claude-project-design.md +238 -0
- data/docs/plans/canvas-doc-location-and-discovery.md +32 -0
- data/docs/plans/org-doc-preview-surfaces.md +293 -0
- data/docs/plans/shadcn-polish-plan.md +417 -0
- data/docs/porting-artifacts.md +252 -0
- data/docs/reference/agent-skills-comparison.md +369 -0
- data/docs/reference/travel-state-prd.artifact.html +780 -0
- data/docs/research/2026-08-17-hotwire-alike-landscape.md +268 -0
- data/docs/research/2026-08-17-hotwire-concept-map.md +404 -0
- data/docs/research/2026-08-22-lazy-fragments-trigger-decision.md +246 -0
- data/docs/research/2026-08-22-learnhotwire-syllabus-coverage.md +239 -0
- data/docs/research/frontend-only-matrix.md +278 -0
- data/docs/research/streamweaver-way-spike-findings.md +529 -0
- data/docs/resource-dsl.md +342 -0
- data/docs/routing.md +302 -0
- data/docs/ruby-ui-comparison.md +319 -0
- data/docs/shared-dsl-fragments.md +356 -0
- data/docs/streamweaver-for-ai-agents.md +401 -0
- data/docs/streamweaver-frontend-vision.md +123 -0
- data/docs/streamweaver_canvas/glimmer_initial_final_layer.rb +77 -0
- data/docs/streamweaver_canvas/save_example.rb +9 -0
- data/docs/templates.md +213 -0
- data/docs/testing.md +155 -0
- data/docs/theming-hooks.md +281 -0
- data/docs/tutorials/the-streamweaver-way.md +882 -0
- data/docs/university/capability-inventory.md +137 -0
- data/docs/university/dependency-survey.md +114 -0
- data/docs/university/design-spec.md +217 -0
- data/docs/university/mockups/code-block-doc-theme-dark.png +0 -0
- data/docs/university/mockups/code-block-doc-theme-light.png +0 -0
- data/docs/university/mockups/course-list-dark.png +0 -0
- data/docs/university/mockups/course-list-light.png +0 -0
- data/docs/university/mockups/course_canvas_mockup.rb +741 -0
- data/docs/university/mockups/step-screen-dark.png +0 -0
- data/docs/university/mockups/step-screen-light.png +0 -0
- data/docs/university/roadmap.md +72 -0
- data/docs/university/send-to-coworker.md +77 -0
- data/docs/university/worker-session-mining.md +109 -0
- data/docs/visual/browser-rendering-spike.md +173 -0
- data/docs/visual/builder-visual-plan-analysis.md +378 -0
- data/docs/visual/sw-plan-format-exploration.md +511 -0
- data/docs/visual/sw-plan-rendering-deep-dive.md +453 -0
- data/docs/visual-skills/PROGRESS.md +60 -0
- data/docs/visual-skills/SESSION-CONTEXT.md +207 -0
- data/docs/visual-skills/analysis/components.md +317 -0
- data/docs/visual-skills/analysis/overlap.md +360 -0
- data/docs/visual-skills/analysis/pi-design-deck.md +1102 -0
- data/docs/visual-skills/analysis/streamweaver-inventory.md +93 -0
- data/docs/visual-skills/analysis/unified-specs.feature +1003 -0
- data/docs/visual-skills/analysis/visual-explainer.md +964 -0
- data/docs/visual-skills/blog/blog-series-outline.md +39 -0
- data/docs/visual-skills/blog/token-efficiency.md +73 -0
- data/docs/visual-skills/design/architecture.md +1725 -0
- data/docs/visual-skills/design/codex-review.md +340 -0
- data/docs/visual-skills/design/dhh-review.md +209 -0
- data/docs/visual-skills/design/evolution.md +100 -0
- data/docs/visual-skills/design/gemini-review.md +55 -0
- data/docs/visual-skills/design/review-synthesis.md +149 -0
- data/docs/visual-skills/implementation/STATE.md +22 -0
- data/docs/visual-skills/implementation/plan.md +347 -0
- data/docs/visual-skills/implementation/spike-findings.md +237 -0
- data/docs/visual-skills/implementation/tasks.md +702 -0
- data/docs/visual-skills/lessons-learned/process.md +145 -0
- data/examples/README.md +96 -0
- data/examples/advanced/all_components.rb +177 -0
- data/examples/advanced/examples_browser.rb +394 -0
- data/examples/advanced/teachables_browser.rb +261 -0
- data/examples/advanced/theme_tweaker.rb +366 -0
- data/examples/advanced/tutorial.rb +1528 -0
- data/examples/agentic/agentic_form.rb +30 -0
- data/examples/agentic/agentic_form_autoclose.rb +30 -0
- data/examples/agentic/cultivation_tracker.rb +69 -0
- data/examples/basic/hello_world.rb +22 -0
- data/examples/basic/opal_tabs_table.rb +44 -0
- data/examples/basic/todo_list.rb +39 -0
- data/examples/button_loading_test.rb +36 -0
- data/examples/canvas/mermaid_canvas_demo.sh +151 -0
- data/examples/charts/bar_chart_demo.rb +57 -0
- data/examples/charts/line_chart_demo.rb +73 -0
- data/examples/charts/pie_area_demo.rb +87 -0
- data/examples/charts/stacked_bar_chart_demo.rb +69 -0
- data/examples/claude_code/README.md +85 -0
- data/examples/claude_code/codebreaker/.claude/commands/infiltrate.md +632 -0
- data/examples/claude_code/codebreaker/.claude/settings.json +11 -0
- data/examples/claude_code/codebreaker/.claude/settings.local.json +17 -0
- data/examples/claude_code/codebreaker/.claude/skills/infiltrate.md +0 -0
- data/examples/claude_code/codebreaker/README.md +213 -0
- data/examples/claude_code/tutorial/.claude/commands/learn.md +453 -0
- data/examples/claude_code/tutorial/README.md +102 -0
- data/examples/claude_code/verification_flow/.claude/commands/verify.md +171 -0
- data/examples/claude_code/verification_flow/README.md +126 -0
- data/examples/components/annotated_code_demo.rb +54 -0
- data/examples/components/callout_demo.rb +33 -0
- data/examples/components/checkbox_group_demo.rb +46 -0
- data/examples/components/design_review.css +468 -0
- data/examples/components/design_review_demo.rb +44 -0
- data/examples/components/design_review_dsl.rb +300 -0
- data/examples/components/diff_block_demo.rb +112 -0
- data/examples/components/events_demo.rb +184 -0
- data/examples/components/form_demo.rb +62 -0
- data/examples/components/lesson_demo.rb +53 -0
- data/examples/components/markdown_demo.rb +110 -0
- data/examples/components/mermaid_demo.rb +166 -0
- data/examples/components/pareto_set.rb +64 -0
- data/examples/components/prd_demo.rb +21 -0
- data/examples/components/prd_dsl.rb +385 -0
- data/examples/components/quiz_demo.rb +47 -0
- data/examples/components/run_viewer_demo.rb +100 -0
- data/examples/components/score_and_collapsible_demo.rb +82 -0
- data/examples/components/select_stale_value_smoke_test.rb +39 -0
- data/examples/components/table_demo.rb +247 -0
- data/examples/components/timer_with_state_demo.rb +58 -0
- data/examples/components/todo_due_dates.rb +63 -0
- data/examples/components/uat_gaps_demo.rb +112 -0
- data/examples/dashboard/feed_simulator.rb +60 -0
- data/examples/dashboard/live_dashboard.rb +80 -0
- data/examples/dashboard_components.rb +108 -0
- data/examples/deferred_fragments_demo.rb +62 -0
- data/examples/generate_more_spike/README.md +63 -0
- data/examples/generate_more_spike/app.rb +441 -0
- data/examples/git_health.sh +401 -0
- data/examples/layout/layout_components_demo.rb +174 -0
- data/examples/layout/modal_demo.rb +215 -0
- data/examples/layout/navigation_demo.rb +227 -0
- data/examples/layout/route_tabs_demo.rb +52 -0
- data/examples/layout/routing_demo.rb +52 -0
- data/examples/layout/scroll_box_demo.rb +154 -0
- data/examples/lazy_fragments_demo.rb +151 -0
- data/examples/my_todos/README.md +29 -0
- data/examples/my_todos/my_todos.rb +312 -0
- data/examples/my_todos/store.rb +86 -0
- data/examples/opal/reactive_demo.rb +62 -0
- data/examples/opal/scenarios/s1_counter.rb +15 -0
- data/examples/opal/scenarios/s2_search_filter.rb +17 -0
- data/examples/opal/scenarios/s3_sibling_tabs.rb +25 -0
- data/examples/opal/scenarios/s4_shopping_cart.rb +34 -0
- data/examples/opal/scenarios/s5_watch.rb +39 -0
- data/examples/opal/scenarios/s6_on_start.rb +20 -0
- data/examples/opal/scenarios/s7_wizard.rb +33 -0
- data/examples/opal/scenarios/s8_loan_calculator.rb +21 -0
- data/examples/opal/scenarios/s9_dashboard.rb +27 -0
- data/examples/operations_dashboard_demo.rb +167 -0
- data/examples/panel_demo.sh +447 -0
- data/examples/parity/assets/tyrion_slice.css +86 -0
- data/examples/parity/rivet_people_slice.rb +182 -0
- data/examples/parity/tyrion_components.css +777 -0
- data/examples/parity/tyrion_warroom_components.rb +414 -0
- data/examples/parity/tyrion_warroom_slice.rb +549 -0
- data/examples/puma_dev/README.md +124 -0
- data/examples/puma_dev/config.ru +34 -0
- data/examples/puma_dev/standalone_app.rb +34 -0
- data/examples/scaffolding/blog.rb +49 -0
- data/examples/scaffolding/utf_lite.rb +105 -0
- data/examples/styling/feedback_demo.rb +229 -0
- data/examples/styling/style_showcase.rb +172 -0
- data/examples/styling/theme_demo.rb +508 -0
- data/examples/timer_health_checker.rb +111 -0
- data/examples/timer_showcase.rb +131 -0
- data/examples/tutorials/render_markdown.rb +112 -0
- data/examples/tutorials/streamweaver_way_tutorial.rb +44 -0
- data/examples/tutorials/tutorial_content.rb +1082 -0
- data/examples/visual_skills/design_deck_demo.rb +314 -0
- data/examples/visual_skills/explainer_demo.rb +499 -0
- data/exe/streamweaver +9 -0
- data/gsd/ROADMAP-1.0.md +167 -0
- data/gsd/STATE.md +13 -0
- data/gsd/research/market-positioning-research.md +139 -0
- data/gsd/research/production-patterns-research.md +270 -0
- data/gsd/research/repo-audit-1.0.md +274 -0
- data/lib/stream_weaver/action_token.rb +58 -0
- data/lib/stream_weaver/adapter/alpinejs.rb +8249 -0
- data/lib/stream_weaver/adapter/base.rb +591 -0
- data/lib/stream_weaver/adapter/opal.rb +334 -0
- data/lib/stream_weaver/adapter/static.rb +1118 -0
- data/lib/stream_weaver/admin.rb +176 -0
- data/lib/stream_weaver/app.rb +1821 -0
- data/lib/stream_weaver/assets/js/sw-copy.js +50 -0
- data/lib/stream_weaver/assets/js/sw-heredoc-rewrite.js +68 -0
- data/lib/stream_weaver/assets/js/sw-keyboard.js +165 -0
- data/lib/stream_weaver/assets/js/sw-mermaid-zoom.js +621 -0
- data/lib/stream_weaver/assets/js/sw-route-tabs.js +53 -0
- data/lib/stream_weaver/assets/js/sw-sidebar-toc.js +116 -0
- data/lib/stream_weaver/assets/js/sw-slide-nav.js +103 -0
- data/lib/stream_weaver/canvas/bridge.rb +239 -0
- data/lib/stream_weaver/canvas/bridge_server.rb +646 -0
- data/lib/stream_weaver/canvas/client.rb +298 -0
- data/lib/stream_weaver/canvas/doc_roots.rb +233 -0
- data/lib/stream_weaver/canvas/doc_store.rb +252 -0
- data/lib/stream_weaver/canvas/gist_publisher.rb +264 -0
- data/lib/stream_weaver/canvas/gist_save_handler.rb +89 -0
- data/lib/stream_weaver/canvas/gist_store.rb +135 -0
- data/lib/stream_weaver/canvas/helpers.rb +109 -0
- data/lib/stream_weaver/canvas/history.rb +90 -0
- data/lib/stream_weaver/canvas/protocol.rb +86 -0
- data/lib/stream_weaver/canvas/reader.rb +853 -0
- data/lib/stream_weaver/canvas/save_doc_widget.rb +457 -0
- data/lib/stream_weaver/canvas/scroll_top_hint.rb +21 -0
- data/lib/stream_weaver/canvas/session.rb +132 -0
- data/lib/stream_weaver/cli.rb +3235 -0
- data/lib/stream_weaver/component_assets.rb +70 -0
- data/lib/stream_weaver/component_registry.rb +67 -0
- data/lib/stream_weaver/component_renderer.rb +48 -0
- data/lib/stream_weaver/components/annotated_code.rb +53 -0
- data/lib/stream_weaver/components/api_endpoint.rb +42 -0
- data/lib/stream_weaver/components/callout.rb +59 -0
- data/lib/stream_weaver/components/chart.rb +84 -0
- data/lib/stream_weaver/components/code_block.rb +77 -0
- data/lib/stream_weaver/components/comparison.rb +39 -0
- data/lib/stream_weaver/components/decision.rb +38 -0
- data/lib/stream_weaver/components/deck/close_overlay.rb +84 -0
- data/lib/stream_weaver/components/deck/confirmation_bar.rb +60 -0
- data/lib/stream_weaver/components/deck/deck_option.rb +61 -0
- data/lib/stream_weaver/components/deck/deck_slide.rb +77 -0
- data/lib/stream_weaver/components/deck/deck_state.rb +469 -0
- data/lib/stream_weaver/components/deck/deck_summary.rb +73 -0
- data/lib/stream_weaver/components/deck/design_deck.rb +60 -0
- data/lib/stream_weaver/components/deck/generate_more_controls.rb +84 -0
- data/lib/stream_weaver/components/deck/model_selector.rb +84 -0
- data/lib/stream_weaver/components/deck/skeleton_placeholder.rb +36 -0
- data/lib/stream_weaver/components/diff_block.rb +125 -0
- data/lib/stream_weaver/components/doc_header.rb +57 -0
- data/lib/stream_weaver/components/image_block.rb +70 -0
- data/lib/stream_weaver/components/implementation_map.rb +33 -0
- data/lib/stream_weaver/components/keyboard_shortcuts.rb +94 -0
- data/lib/stream_weaver/components/kpi_dashboard.rb +78 -0
- data/lib/stream_weaver/components/mermaid.rb +79 -0
- data/lib/stream_weaver/components/pipeline.rb +63 -0
- data/lib/stream_weaver/components/sidebar_toc.rb +46 -0
- data/lib/stream_weaver/components/slide_container.rb +141 -0
- data/lib/stream_weaver/components/timeline_event.rb +58 -0
- data/lib/stream_weaver/components/wireframe.rb +29 -0
- data/lib/stream_weaver/components/wireframe_block.rb +34 -0
- data/lib/stream_weaver/components.rb +2711 -0
- data/lib/stream_weaver/css.rb +230 -0
- data/lib/stream_weaver/dev_fallback_overlay.rb +61 -0
- data/lib/stream_weaver/display_dsl.rb +1004 -0
- data/lib/stream_weaver/export/html_exporter.rb +478 -0
- data/lib/stream_weaver/feed.rb +34 -0
- data/lib/stream_weaver/feed_builder.rb +31 -0
- data/lib/stream_weaver/fonts.rb +33 -0
- data/lib/stream_weaver/interaction_runner.rb +487 -0
- data/lib/stream_weaver/iterm.rb +460 -0
- data/lib/stream_weaver/layout_registry.rb +92 -0
- data/lib/stream_weaver/opal/bridge.rb +52 -0
- data/lib/stream_weaver/opal/builder.rb +193 -0
- data/lib/stream_weaver/opal/env.rb +29 -0
- data/lib/stream_weaver/opal/reactive_state.rb +75 -0
- data/lib/stream_weaver/opal/regexp_anchor_patch.rb +62 -0
- data/lib/stream_weaver/opal/renderer.rb +77 -0
- data/lib/stream_weaver/opal/runtime.rb +250 -0
- data/lib/stream_weaver/opal/shell.rb +145 -0
- data/lib/stream_weaver/opal/string_bridge.rb +50 -0
- data/lib/stream_weaver/opal/stubs/diff.min.js +1 -0
- data/lib/stream_weaver/opal/stubs/digest.rb +15 -0
- data/lib/stream_weaver/opal/stubs/marked.umd.js +79 -0
- data/lib/stream_weaver/opal/stubs/md5.rb +3 -0
- data/lib/stream_weaver/opal/stubs/morphdom.min.js +775 -0
- data/lib/stream_weaver/opal/stubs/prism-tomorrow.min.css +1 -0
- data/lib/stream_weaver/opal/stubs/prism.min.js +1967 -0
- data/lib/stream_weaver/opal_entry.rb +136 -0
- data/lib/stream_weaver/org/inline.rb +95 -0
- data/lib/stream_weaver/org/reader.rb +561 -0
- data/lib/stream_weaver/org/recording_context.rb +86 -0
- data/lib/stream_weaver/org/source_splitter.rb +63 -0
- data/lib/stream_weaver/org/writer.rb +300 -0
- data/lib/stream_weaver/page_shell.rb +530 -0
- data/lib/stream_weaver/portfile.rb +79 -0
- data/lib/stream_weaver/pushable.rb +42 -0
- data/lib/stream_weaver/resource/default_views.rb +111 -0
- data/lib/stream_weaver/resource/field_input.rb +25 -0
- data/lib/stream_weaver/resource/state_keys.rb +16 -0
- data/lib/stream_weaver/resource/store.rb +18 -0
- data/lib/stream_weaver/resource.rb +104 -0
- data/lib/stream_weaver/server.rb +1471 -0
- data/lib/stream_weaver/service.rb +1240 -0
- data/lib/stream_weaver/service_client.rb +104 -0
- data/lib/stream_weaver/session_store.rb +186 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/SKILL.md +66 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/examples/canvas-safe-showcase.rb +98 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/actions-and-buttons.md +47 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/charts-and-diagrams.md +38 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/deck.md +32 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/inputs-and-forms.md +65 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/tabs-and-navigation.md +53 -0
- data/lib/stream_weaver/skills/streamweaver-doc-builder/SKILL.md +298 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/SKILL.md +130 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/design-review-example.css +468 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/design-review-example.rb +39 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/design-review-example_dsl.rb +300 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/doc-parity-example.rb +24 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/doc-parity-example_dsl.rb +385 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/references/checkpoints-and-forms.md +28 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/references/cleanup-and-panel.md +44 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/references/example-gallery.md +8 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/references/persistence.md +58 -0
- data/lib/stream_weaver/skills/streamweaver-way/SKILL.md +396 -0
- data/lib/stream_weaver/skills/visual-plan/SKILL.md +201 -0
- data/lib/stream_weaver/skills/visual-recap/SKILL.md +244 -0
- data/lib/stream_weaver/streamer.rb +63 -0
- data/lib/stream_weaver/templates/choices.rb +149 -0
- data/lib/stream_weaver/templates/code.rb +157 -0
- data/lib/stream_weaver/templates/confirm.rb +110 -0
- data/lib/stream_weaver/templates/diff.rb +195 -0
- data/lib/stream_weaver/templates/info.rb +142 -0
- data/lib/stream_weaver/templates/table.rb +169 -0
- data/lib/stream_weaver/templates/wizard.rb +271 -0
- data/lib/stream_weaver/theme/auto_mode.rb +116 -0
- data/lib/stream_weaver/theme/presets.rb +555 -0
- data/lib/stream_weaver/theme.rb +639 -0
- data/lib/stream_weaver/university/canvas.rb +924 -0
- data/lib/stream_weaver/university/course.rb +699 -0
- data/lib/stream_weaver/university/demos/counter.rb +33 -0
- data/lib/stream_weaver/university/demos/dashboard.rb +147 -0
- data/lib/stream_weaver/university/demos/decision_form.rb +143 -0
- data/lib/stream_weaver/university/demos.rb +50 -0
- data/lib/stream_weaver/university/listener.rb +467 -0
- data/lib/stream_weaver/university/progress.rb +201 -0
- data/lib/stream_weaver/university/runner.rb +138 -0
- data/lib/stream_weaver/university/scripts/growing_doc.rb +609 -0
- data/lib/stream_weaver/university/scripts/growing_doc_state.rb +91 -0
- data/lib/stream_weaver/utils.rb +31 -0
- data/lib/stream_weaver/version.rb +5 -0
- data/lib/stream_weaver/views/canvas/reader_layout.erb +800 -0
- data/lib/stream_weaver/views.rb +3940 -0
- data/lib/stream_weaver.rb +122 -0
- data/llms.txt +1269 -0
- data/sig/stream_weaver.rbs +4 -0
- metadata +598 -0
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "inline"
|
|
4
|
+
|
|
5
|
+
module StreamWeaver
|
|
6
|
+
module Org
|
|
7
|
+
# Converts StreamWeaver-flavored org text back into DSL body text (the
|
|
8
|
+
# same shape canvas-read already instance_evals). See
|
|
9
|
+
# docs/superpowers/specs/2026-08-13-org-doc-format-design.md for the
|
|
10
|
+
# format this parses.
|
|
11
|
+
#
|
|
12
|
+
# Verified 2026-08-13 against synthetic scenarios AND the real,
|
|
13
|
+
# untracked example doc that motivated this feature, covering:
|
|
14
|
+
# infinite-loop safety on unterminated src/quote/property-drawer
|
|
15
|
+
# blocks, standalone callout/card/comparison (no headline) NOT
|
|
16
|
+
# misclassified as doc_header, a card with a nested mermaid src block
|
|
17
|
+
# emitting both a real `mermaid` call and surrounding `md` prose (not
|
|
18
|
+
# flattened), preamble lines never leaking as stray `md` calls,
|
|
19
|
+
# TOC_LABEL overrides, table inline-emphasis conversion, a
|
|
20
|
+
# multi-paragraph `md` block staying as ONE call (not split on its
|
|
21
|
+
# internal blank line), a `markdown: false` table's literal
|
|
22
|
+
# emphasis-like characters surviving the round trip untouched, and a
|
|
23
|
+
# full realistic document producing syntactically valid Ruby end to end.
|
|
24
|
+
class Reader
|
|
25
|
+
VARIANT_EMOJI = {
|
|
26
|
+
"⚠️" => :warning,
|
|
27
|
+
"✅" => :success,
|
|
28
|
+
"💡" => :tip,
|
|
29
|
+
"ℹ️" => :info,
|
|
30
|
+
"⚖️" => :decision,
|
|
31
|
+
"❌" => :error,
|
|
32
|
+
"🔺" => :risk
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
HEADLINE_RE = /\A(\*+)\s+(\d+(?:\.\d+)*)\s+(.+)\z/
|
|
36
|
+
SRC_BEGIN_RE = /\A#\+begin_src(?:\s+(.*))?\z/i
|
|
37
|
+
# Any "#+KEY: value" org keyword line, not just the two StreamWeaver reads
|
|
38
|
+
# itself: an unrecognized keyword used to fall through to the generic
|
|
39
|
+
# paragraph branch and derail whatever followed it, so a doc carrying a
|
|
40
|
+
# plain #+AUTHOR:/#+DATE:/#+FILETAGS: -- or the #+TYPE: a UKF wiki member
|
|
41
|
+
# needs to be indexed -- failed to parse at all. Two exclusions: block
|
|
42
|
+
# directives (#+begin_src/#+begin_quote and their #+end_ partners, which
|
|
43
|
+
# carry no colon anyway, kept explicit for the reader) and
|
|
44
|
+
# #+ATTR_STREAMWEAVER:, which is consulted a few lines LATER (see #chunks)
|
|
45
|
+
# -- matching it here would consume it as preamble and silently drop the
|
|
46
|
+
# attribute from the table it belongs to.
|
|
47
|
+
PREAMBLE_RE = /\A#\+(?!begin_|end_|ATTR_STREAMWEAVER:)\w+:/i
|
|
48
|
+
ATTR_RE = /\A#\+ATTR_STREAMWEAVER:\s*(.*)\z/i
|
|
49
|
+
|
|
50
|
+
def self.to_dsl(org_text)
|
|
51
|
+
new(org_text).call
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# True when `text` carries the `#+STREAMWEAVER_DSL:` marker within its
|
|
55
|
+
# first 10 lines -- the same content-based check content.js/sandbox.js
|
|
56
|
+
# already do client-side (there's no way to share the regex literally
|
|
57
|
+
# across Ruby and JS, so each surface keeps its own copy; this is the
|
|
58
|
+
# canonical Ruby-side one, for anything server-side that needs to tell
|
|
59
|
+
# a StreamWeaver org doc apart from plain org/prose before parsing it).
|
|
60
|
+
# Looser than the `\s*1\s*\z`-anchored check `initialize` uses for its
|
|
61
|
+
# own @streamweaver_document flag (any digit, no end anchor) so a
|
|
62
|
+
# future version bump stays recognized here without an update.
|
|
63
|
+
def self.streamweaver_org?(text)
|
|
64
|
+
return false unless text.is_a?(String)
|
|
65
|
+
|
|
66
|
+
text.each_line.first(10).any? { |line| line.match?(/\A#\+STREAMWEAVER_DSL:\s*\d+/i) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def initialize(org_text)
|
|
70
|
+
@lines = org_text.lines.map(&:chomp)
|
|
71
|
+
|
|
72
|
+
title_line = @lines.find { |line| line.match?(/\A#\+TITLE:/i) }
|
|
73
|
+
@title = title_line&.sub(/\A#\+TITLE:\s*/i, "")
|
|
74
|
+
|
|
75
|
+
@streamweaver_document =
|
|
76
|
+
@lines.any? { |line| line.match?(/\A#\+STREAMWEAVER_DSL:\s*1\s*\z/i) }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def call
|
|
80
|
+
chunk_list = chunks
|
|
81
|
+
|
|
82
|
+
# sidebar_toc is derived from depth-1 headlines, in document order,
|
|
83
|
+
# using TOC_LABEL when present and falling back to the headline's
|
|
84
|
+
# own title otherwise -- never hand-authored.
|
|
85
|
+
toc_entries = chunk_list.filter_map do |chunk|
|
|
86
|
+
next unless chunk[:type] == :headline && chunk[:depth] == 1
|
|
87
|
+
|
|
88
|
+
{ id: chunk.fetch(:custom_id), label: chunk[:toc_label] || chunk.fetch(:title) }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
output = []
|
|
92
|
+
header_insert_at = 0
|
|
93
|
+
unless toc_entries.empty?
|
|
94
|
+
entries = toc_entries.map { |e| "{ id: #{e[:id].inspect}, label: #{e[:label].inspect} }" }
|
|
95
|
+
output << "sidebar_toc sections: [#{entries.join(', ')}]"
|
|
96
|
+
header_insert_at = 1
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
seen_headline = false
|
|
100
|
+
emitted_doc_header = false
|
|
101
|
+
i = 0
|
|
102
|
+
|
|
103
|
+
while i < chunk_list.length
|
|
104
|
+
chunk = chunk_list[i]
|
|
105
|
+
|
|
106
|
+
case chunk[:type]
|
|
107
|
+
when :headline
|
|
108
|
+
seen_headline = true
|
|
109
|
+
output << emit_headline(chunk)
|
|
110
|
+
|
|
111
|
+
when :quote
|
|
112
|
+
# Rule 1 (position, NOT content shape -- see doc_header_chunk?
|
|
113
|
+
# below for why): only the doc_header preamble block qualifies,
|
|
114
|
+
# never an arbitrary headline-less callout/card.
|
|
115
|
+
if !seen_headline && !emitted_doc_header && doc_header_chunk?(chunk, i)
|
|
116
|
+
output << emit_doc_header(chunk)
|
|
117
|
+
emitted_doc_header = true
|
|
118
|
+
else
|
|
119
|
+
marker = chunk[:lines].first.to_s
|
|
120
|
+
body = chunk[:lines].drop(1)
|
|
121
|
+
|
|
122
|
+
# Rule 2: before/after comparison pairing.
|
|
123
|
+
if (match = marker.match(/\A\*◀ Before — (.+)\*\z/))
|
|
124
|
+
following = chunk_list[i + 1]
|
|
125
|
+
after_marker = following[:lines].first.to_s if following&.dig(:type) == :quote
|
|
126
|
+
after_match = after_marker&.match(/\A\*▶ After — (.+)\*\z/)
|
|
127
|
+
raise ArgumentError, "malformed comparison: before block is not followed by an after block" unless after_match
|
|
128
|
+
|
|
129
|
+
output << emit_comparison(match[1], body, after_match[1], following[:lines].drop(1))
|
|
130
|
+
i += 1 # consume the paired 'after' chunk too
|
|
131
|
+
elsif marker.match?(/\A\*▶ After — /)
|
|
132
|
+
raise ArgumentError, "malformed comparison: after block has no preceding before block"
|
|
133
|
+
# Rule 3: reserved-emoji callout.
|
|
134
|
+
elsif (match = callout_marker_match(marker))
|
|
135
|
+
output << emit_callout(VARIANT_EMOJI.fetch(match[1]), match[2], body)
|
|
136
|
+
# Rule 4: card fallback.
|
|
137
|
+
else
|
|
138
|
+
output << emit_card(marker, body)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
else
|
|
143
|
+
output << emit(chunk)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
i += 1
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# A doc_header with neither eyebrow nor pills has no quote block at
|
|
150
|
+
# all (Writer only emits one when it has content to put in it), so
|
|
151
|
+
# the loop above never gets a chance to match it -- synthesize a
|
|
152
|
+
# title-only call here instead, in the position it would have
|
|
153
|
+
# occupied (right after sidebar_toc, before everything else).
|
|
154
|
+
if @streamweaver_document && @title && !emitted_doc_header
|
|
155
|
+
output.insert(header_insert_at, emit_doc_header(nil))
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
output.reject!(&:empty?)
|
|
159
|
+
output.empty? ? "" : "#{output.join("\n\n")}\n"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
private
|
|
163
|
+
|
|
164
|
+
# Splits org text into typed chunks. Every delimited construct
|
|
165
|
+
# (headline property drawer, quote block, src block) has an EOF guard
|
|
166
|
+
# that raises ArgumentError instead of looping forever when
|
|
167
|
+
# unterminated -- this is the fix for the infinite-loop bug.
|
|
168
|
+
#
|
|
169
|
+
# Callable recursively on a sub-slice of lines (see emit_body) so a
|
|
170
|
+
# quote block's own body can be re-chunked to recognize a nested src
|
|
171
|
+
# block instead of being flattened into one md call.
|
|
172
|
+
def chunks(lines = @lines)
|
|
173
|
+
result = []
|
|
174
|
+
i = 0
|
|
175
|
+
pending_attr = nil
|
|
176
|
+
|
|
177
|
+
while i < lines.length
|
|
178
|
+
line = lines[i]
|
|
179
|
+
|
|
180
|
+
if line.empty?
|
|
181
|
+
i += 1
|
|
182
|
+
next
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Preamble metadata lines are consumed by #initialize and must
|
|
186
|
+
# never fall through to the generic paragraph branch -- this is
|
|
187
|
+
# the fix for the preamble-leak bug.
|
|
188
|
+
if line.match?(PREAMBLE_RE)
|
|
189
|
+
i += 1
|
|
190
|
+
next
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# #+ATTR_STREAMWEAVER: :markdown nil marks the very next table as
|
|
194
|
+
# markdown: false -- attach it to that table chunk (see emit_table)
|
|
195
|
+
# rather than treating it as prose. Any other chunk type clears a
|
|
196
|
+
# stale pending attribute rather than silently misapplying it.
|
|
197
|
+
if (attr_match = line.match(ATTR_RE))
|
|
198
|
+
pending_attr = attr_match[1]
|
|
199
|
+
i += 1
|
|
200
|
+
next
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
if (match = line.match(HEADLINE_RE))
|
|
204
|
+
depth = match[1].length
|
|
205
|
+
number = match[2]
|
|
206
|
+
title = match[3]
|
|
207
|
+
custom_id = nil
|
|
208
|
+
toc_label = nil
|
|
209
|
+
i += 1
|
|
210
|
+
|
|
211
|
+
if lines[i] == ":PROPERTIES:"
|
|
212
|
+
i += 1
|
|
213
|
+
while i < lines.length && lines[i] != ":END:"
|
|
214
|
+
property = lines[i]
|
|
215
|
+
custom_id = $1 if property =~ /\A:CUSTOM_ID:\s*(.+)\z/
|
|
216
|
+
toc_label = $1 if property =~ /\A:TOC_LABEL:\s*(.+)\z/
|
|
217
|
+
i += 1
|
|
218
|
+
end
|
|
219
|
+
raise ArgumentError, "unterminated property drawer for #{number}" if i >= lines.length
|
|
220
|
+
|
|
221
|
+
i += 1
|
|
222
|
+
end
|
|
223
|
+
raise ArgumentError, "headline #{number.inspect} has no CUSTOM_ID" unless custom_id
|
|
224
|
+
|
|
225
|
+
result << { type: :headline, depth: depth, number: number, title: title, custom_id: custom_id, toc_label: toc_label }
|
|
226
|
+
pending_attr = nil
|
|
227
|
+
next
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
if line.casecmp?("#+begin_quote")
|
|
231
|
+
body = []
|
|
232
|
+
i += 1
|
|
233
|
+
while i < lines.length && !lines[i].casecmp?("#+end_quote")
|
|
234
|
+
body << lines[i]
|
|
235
|
+
i += 1
|
|
236
|
+
end
|
|
237
|
+
raise ArgumentError, "unterminated quote block" if i >= lines.length
|
|
238
|
+
|
|
239
|
+
i += 1
|
|
240
|
+
result << { type: :quote, lines: body }
|
|
241
|
+
pending_attr = nil
|
|
242
|
+
next
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
if (match = line.match(SRC_BEGIN_RE))
|
|
246
|
+
header = match[1].to_s.strip
|
|
247
|
+
body = []
|
|
248
|
+
i += 1
|
|
249
|
+
while i < lines.length && !lines[i].casecmp?("#+end_src")
|
|
250
|
+
body << lines[i]
|
|
251
|
+
i += 1
|
|
252
|
+
end
|
|
253
|
+
raise ArgumentError, "unterminated source block" if i >= lines.length
|
|
254
|
+
|
|
255
|
+
i += 1
|
|
256
|
+
result << { type: :src, header: header, lines: body }
|
|
257
|
+
pending_attr = nil
|
|
258
|
+
next
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
if line.start_with?("|")
|
|
262
|
+
rows = []
|
|
263
|
+
while i < lines.length && lines[i].start_with?("|")
|
|
264
|
+
rows << lines[i]
|
|
265
|
+
i += 1
|
|
266
|
+
end
|
|
267
|
+
result << { type: :table, rows: rows, attr: pending_attr }
|
|
268
|
+
pending_attr = nil
|
|
269
|
+
next
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Paragraph collection: a blank line does NOT always end a
|
|
273
|
+
# paragraph chunk (fix for the multi-paragraph md-splitting bug —
|
|
274
|
+
# see revision note). If, after one or more blank lines, more
|
|
275
|
+
# plain text follows rather than a structural element, the blank
|
|
276
|
+
# line was an internal paragraph break within a single logical
|
|
277
|
+
# prose block (one `md` call with multiple <p> tags), not a
|
|
278
|
+
# component boundary -- keep collecting instead of splitting.
|
|
279
|
+
paragraph = []
|
|
280
|
+
loop do
|
|
281
|
+
break if i >= lines.length || structural_start?(lines[i])
|
|
282
|
+
|
|
283
|
+
if lines[i].empty?
|
|
284
|
+
j = i
|
|
285
|
+
j += 1 while j < lines.length && lines[j].empty?
|
|
286
|
+
break if j >= lines.length || structural_start?(lines[j])
|
|
287
|
+
|
|
288
|
+
paragraph << "" # normalize any blank-line run to one separator
|
|
289
|
+
i = j
|
|
290
|
+
next
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
paragraph << lines[i]
|
|
294
|
+
i += 1
|
|
295
|
+
end
|
|
296
|
+
raise ArgumentError, "unexpected Org directive: #{lines[i].inspect}" if paragraph.empty?
|
|
297
|
+
|
|
298
|
+
result << { type: :paragraph, lines: paragraph }
|
|
299
|
+
pending_attr = nil # a paragraph between an ATTR line and a later,
|
|
300
|
+
# unrelated table must not let that table inherit the attribute --
|
|
301
|
+
# only a directly-adjacent table consumes it (see #emit_table).
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
result
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def structural_start?(line)
|
|
308
|
+
line.match?(HEADLINE_RE) || line.casecmp?("#+begin_quote") ||
|
|
309
|
+
line.match?(SRC_BEGIN_RE) || line.start_with?("|") ||
|
|
310
|
+
line.match?(PREAMBLE_RE) || line.match?(ATTR_RE)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def emit(chunk)
|
|
314
|
+
case chunk.fetch(:type)
|
|
315
|
+
when :paragraph then emit_paragraph(chunk)
|
|
316
|
+
when :table then emit_table(chunk)
|
|
317
|
+
when :src then emit_src(chunk)
|
|
318
|
+
when :headline then emit_headline(chunk)
|
|
319
|
+
else raise ArgumentError, "cannot directly emit #{chunk[:type].inspect}"
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def emit_headline(chunk)
|
|
324
|
+
"doc_section_header #{chunk[:number].inspect}, #{chunk[:title].inspect}, id: #{chunk[:custom_id].inspect}"
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def emit_paragraph(chunk)
|
|
328
|
+
text = Inline.org_to_md(chunk[:lines].join("\n"))
|
|
329
|
+
heredoc("md ", text, "MD")
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def emit_table(chunk)
|
|
333
|
+
# markdown: true is the default (absent #+ATTR_STREAMWEAVER: :markdown
|
|
334
|
+
# nil) -- only that explicit marker (see #chunks) means the Writer
|
|
335
|
+
# copied these cells byte-identical from a markdown: false table, so
|
|
336
|
+
# skip Inline.org_to_md for them too. Forcing markdown: true
|
|
337
|
+
# unconditionally (an earlier version of this method did) silently
|
|
338
|
+
# reinterprets literal characters -- e.g. "5 min *after* the
|
|
339
|
+
# failure" -- as real emphasis on the way back. Confirmed against
|
|
340
|
+
# real content; see the design spec's Tables section.
|
|
341
|
+
markdown = !(chunk[:attr].to_s =~ /:markdown\s+nil/)
|
|
342
|
+
rows = chunk[:rows].reject { |row| table_separator?(row) }.map { |row| parse_table_row(row) }
|
|
343
|
+
rows = rows.map { |row| row.map { |cell| markdown ? Inline.org_to_md(cell) : cell } }
|
|
344
|
+
raise ArgumentError, "table has no header row" if rows.empty?
|
|
345
|
+
|
|
346
|
+
headers, *data = rows
|
|
347
|
+
data_dsl = data.map { |row| " [#{row.map(&:inspect).join(', ')}]" }.join(",\n")
|
|
348
|
+
body = data_dsl.empty? ? " rows: []" : " rows: [\n#{data_dsl}\n ]"
|
|
349
|
+
markdown_line = markdown ? " markdown: true,\n" : ""
|
|
350
|
+
|
|
351
|
+
<<~RUBY.chomp
|
|
352
|
+
table(
|
|
353
|
+
#{markdown_line} headers: [#{headers.map(&:inspect).join(', ')}],
|
|
354
|
+
#{body}
|
|
355
|
+
)
|
|
356
|
+
RUBY
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def emit_src(chunk)
|
|
360
|
+
tokens = chunk[:header].split(/\s+/)
|
|
361
|
+
lang = tokens.shift
|
|
362
|
+
lang = "text" if lang.nil? || lang.empty?
|
|
363
|
+
code = chunk[:lines].join("\n")
|
|
364
|
+
|
|
365
|
+
# Writer#raw_passthrough's escape hatch (see also its NO_OP_STATEMENT_RE
|
|
366
|
+
# comment): content here is either the verbatim original DSL
|
|
367
|
+
# statement(s) or a "# unrecognized component" comment -- either way,
|
|
368
|
+
# real Ruby text meant to be spliced back in AS-IS, not wrapped in a
|
|
369
|
+
# code_block(...) call (which would misrender it: a fake code block
|
|
370
|
+
# with Prism.js chrome around what used to be, say, a `header1` call).
|
|
371
|
+
return code if tokens.each_cons(2).any? { |key, value| key == ":streamweaver-raw" && value == "t" }
|
|
372
|
+
|
|
373
|
+
zoom = tokens.each_cons(2).any? { |key, value| key == ":zoom" && value == "t" }
|
|
374
|
+
|
|
375
|
+
if lang == "mermaid"
|
|
376
|
+
options = zoom ? ", zoom: true" : ""
|
|
377
|
+
heredoc("mermaid ", code, "MERMAID", suffix: options)
|
|
378
|
+
else
|
|
379
|
+
heredoc("code_block(", code, "TXT", suffix: ", lang: #{lang.inspect})")
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# chunk is nil for a doc_header with neither eyebrow nor pills (see the
|
|
384
|
+
# synthesis call in #call) -- title-only in that case. Otherwise,
|
|
385
|
+
# each non-empty line is classified by its OWN shape rather than a
|
|
386
|
+
# fixed line position, so eyebrow-only, pills-only, and eyebrow+pills
|
|
387
|
+
# all parse correctly regardless of which one is actually present.
|
|
388
|
+
def emit_doc_header(chunk)
|
|
389
|
+
eyebrow = nil
|
|
390
|
+
pills = []
|
|
391
|
+
|
|
392
|
+
if chunk
|
|
393
|
+
chunk[:lines].reject(&:empty?).each do |line|
|
|
394
|
+
if (match = line.match(%r{\A/(.*)/\z}))
|
|
395
|
+
eyebrow = match[1]
|
|
396
|
+
else
|
|
397
|
+
pills.concat(line.split(" · ").map { |pill| format_pill(pill) })
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
<<~RUBY.chomp
|
|
403
|
+
doc_header(
|
|
404
|
+
title: #{@title.inspect},
|
|
405
|
+
eyebrow: #{eyebrow.inspect},
|
|
406
|
+
pills: [#{pills.join(', ')}]
|
|
407
|
+
)
|
|
408
|
+
RUBY
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def format_pill(pill)
|
|
412
|
+
if (match = pill.match(/\A\*\[([A-Za-z_]\w*)\]\s*(.+)\*\z/))
|
|
413
|
+
"{ text: #{match[2].inspect}, variant: #{match[1].to_sym.inspect} }"
|
|
414
|
+
else
|
|
415
|
+
pill.inspect
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def emit_callout(variant, title, body_lines)
|
|
420
|
+
<<~RUBY.chomp
|
|
421
|
+
callout(variant: #{variant.inspect}, title: #{title.inspect}) do
|
|
422
|
+
#{indent_block(emit_body(body_lines), 2)}
|
|
423
|
+
end
|
|
424
|
+
RUBY
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
def emit_card(marker, body_lines)
|
|
428
|
+
match = marker.match(%r{\A\*\[([^\]]+)\]\s*(.+?)\*(?:\s*/\((.+)\)/)?\z})
|
|
429
|
+
if match
|
|
430
|
+
badge, title, meta = match[1], match[2], match[3]
|
|
431
|
+
else
|
|
432
|
+
match = marker.match(%r{\A\*(.+?)\*(?:\s*/\((.+)\)/)?\z})
|
|
433
|
+
raise ArgumentError, "malformed card header: #{marker.inspect}" unless match
|
|
434
|
+
|
|
435
|
+
badge, title, meta = nil, match[1], match[2]
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
arguments = [title.inspect]
|
|
439
|
+
arguments << "badge: #{badge.inspect}" if badge
|
|
440
|
+
arguments << "meta: #{meta.inspect}" if meta
|
|
441
|
+
|
|
442
|
+
<<~RUBY.chomp
|
|
443
|
+
card do
|
|
444
|
+
card_header #{arguments.join(', ')}
|
|
445
|
+
card_body do
|
|
446
|
+
#{indent_block(emit_body(body_lines), 4)}
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
RUBY
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def emit_comparison(before_label, before_lines, after_label, after_lines)
|
|
453
|
+
<<~RUBY.chomp
|
|
454
|
+
comparison(
|
|
455
|
+
before_label: #{before_label.inspect},
|
|
456
|
+
after_label: #{after_label.inspect}
|
|
457
|
+
) do
|
|
458
|
+
before do
|
|
459
|
+
#{indent_block(emit_body(before_lines), 4)}
|
|
460
|
+
end
|
|
461
|
+
after do
|
|
462
|
+
#{indent_block(emit_body(after_lines), 4)}
|
|
463
|
+
end
|
|
464
|
+
end
|
|
465
|
+
RUBY
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# THE fix for the nested-src-in-card bug: a quote block's body is not
|
|
469
|
+
# intrinsically one flat markdown paragraph. Re-chunk it (recursing
|
|
470
|
+
# into #chunks on just these lines) so a mermaid/code src block nested
|
|
471
|
+
# inside a card or callout emits as a real mermaid/code_block call
|
|
472
|
+
# alongside any surrounding md prose, instead of being swallowed into
|
|
473
|
+
# one md heredoc.
|
|
474
|
+
def emit_body(lines)
|
|
475
|
+
chunks(lines).map do |chunk|
|
|
476
|
+
case chunk[:type]
|
|
477
|
+
when :paragraph, :table, :src then emit(chunk)
|
|
478
|
+
else raise ArgumentError, "unsupported #{chunk[:type]} block inside quote body"
|
|
479
|
+
end
|
|
480
|
+
end.join("\n\n")
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# THE fix for the doc_header misclassification bug. An earlier version
|
|
484
|
+
# of this check used content shape alone (eyebrow-line vs. a leading
|
|
485
|
+
# "*"), which is unreliable: a doc_header with no eyebrow whose pills
|
|
486
|
+
# are all variant-tagged renders as "*[warn] Draft* · *[success] v2*"
|
|
487
|
+
# -- syntactically a valid card marker, not just visually similar to
|
|
488
|
+
# one. That ambiguity is irreducible from shape alone (real-content
|
|
489
|
+
# check found it crashing/corrupting via emit_card on exactly this
|
|
490
|
+
# shape). Position is the reliable signal instead: Writer never emits
|
|
491
|
+
# ANY body content before doc_header's own preamble block, so
|
|
492
|
+
# whenever one exists it is unconditionally the very first chunk in
|
|
493
|
+
# the whole document (index 0) -- no other quote block can ever
|
|
494
|
+
# occupy that position while a title is set. Callout/comparison
|
|
495
|
+
# markers still win even at position 0, since they use reserved
|
|
496
|
+
# syntax (a fixed emoji set, "◀ Before —"/"▶ After —") that
|
|
497
|
+
# eyebrow/pills text can never produce.
|
|
498
|
+
#
|
|
499
|
+
# Known limitation this doesn't resolve: a title-only doc_header (no
|
|
500
|
+
# eyebrow/pills, so no preamble block at all -- see the post-loop
|
|
501
|
+
# synthesis in #call) whose very first BODY element, before any
|
|
502
|
+
# section headline, is itself a genuine standalone card. That's an
|
|
503
|
+
# unusual, non-idiomatic document shape (cards normally live inside a
|
|
504
|
+
# section, after a headline), so left unresolved.
|
|
505
|
+
def doc_header_chunk?(chunk, index)
|
|
506
|
+
return false unless @streamweaver_document && @title && index.zero?
|
|
507
|
+
|
|
508
|
+
first_content_line = chunk[:lines].find { |line| !line.empty? }
|
|
509
|
+
return false unless first_content_line
|
|
510
|
+
|
|
511
|
+
!callout_marker_match(first_content_line) && !first_content_line.match?(/\A\*(?:◀|▶)\s/)
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def callout_marker_match(marker)
|
|
515
|
+
emoji_pattern = Regexp.union(VARIANT_EMOJI.keys)
|
|
516
|
+
# Title is optional (match[2] is nil without one) -- a titleless
|
|
517
|
+
# callout's marker is just "*<emoji>*", no trailing space (see
|
|
518
|
+
# Writer#render_component).
|
|
519
|
+
marker.match(/\A\*(#{emoji_pattern})(?:\s+(.+))?\*\z/)
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
def table_separator?(row)
|
|
523
|
+
row.delete(" \t").match?(/\A\|[-+:|]+\|\z/)
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
def parse_table_row(row)
|
|
527
|
+
row.sub(/\A\|/, "").sub(/\|\z/, "").split("|", -1).map(&:strip)
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
# Heredoc-with-trailing-args generation: `, zoom: true` / `, lang: ...)`
|
|
531
|
+
# belong on the OPENING `<<~TAG` line, never appended after the bare
|
|
532
|
+
# closing tag -- get this backwards and the emitted Ruby won't parse.
|
|
533
|
+
# Also guards against a body line that happens to equal the tag itself
|
|
534
|
+
# (which would terminate the heredoc early) by picking a fresh tag.
|
|
535
|
+
def heredoc(prefix, text, base_tag, suffix: "")
|
|
536
|
+
tag = unique_heredoc_tag(base_tag, text)
|
|
537
|
+
content = text.split("\n", -1)
|
|
538
|
+
content.pop if content.last == ""
|
|
539
|
+
content = [""] if content.empty?
|
|
540
|
+
|
|
541
|
+
"#{prefix}<<~#{tag}#{suffix}\n#{content.map { |line| line.empty? ? line : " #{line}" }.join("\n")}\n#{tag}"
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
def unique_heredoc_tag(base_tag, text)
|
|
545
|
+
occupied = text.lines.map(&:chomp)
|
|
546
|
+
candidate = base_tag
|
|
547
|
+
sequence = 1
|
|
548
|
+
while occupied.include?(candidate)
|
|
549
|
+
candidate = "#{base_tag}_#{sequence}"
|
|
550
|
+
sequence += 1
|
|
551
|
+
end
|
|
552
|
+
candidate
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
def indent_block(text, width)
|
|
556
|
+
pad = " " * width
|
|
557
|
+
text.lines(chomp: true).map { |line| "#{pad}#{line}" }.join("\n")
|
|
558
|
+
end
|
|
559
|
+
end
|
|
560
|
+
end
|
|
561
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../display_dsl"
|
|
4
|
+
require_relative "../app"
|
|
5
|
+
|
|
6
|
+
module StreamWeaver
|
|
7
|
+
module Org
|
|
8
|
+
# Evaluates a saved DSL body (the same text canvas-read instance_evals to
|
|
9
|
+
# render HTML) against a bare context that shares StreamWeaver::DisplayDSL's
|
|
10
|
+
# real component-building logic, WITHOUT rendering anything. After eval,
|
|
11
|
+
# `components` holds the exact same Components::* tree (with real
|
|
12
|
+
# #children/#before_children/#after_children) the live renderer would
|
|
13
|
+
# build -- the Writer walks this tree directly instead of re-parsing
|
|
14
|
+
# Ruby source.
|
|
15
|
+
#
|
|
16
|
+
# #state and #render_state exist only because DisplayDSL#table calls them
|
|
17
|
+
# unconditionally (even for a plain static headers:/rows: table, not just
|
|
18
|
+
# column-DSL tables) -- nothing else in this class's supported vocabulary
|
|
19
|
+
# needs them, but table's own body isn't optional to satisfy.
|
|
20
|
+
#
|
|
21
|
+
# #use_theme/#use_layout exist because DocStore.dsl_with_metadata
|
|
22
|
+
# (doc_store.rb) prepends `use_theme :x` / `use_layout :y` lines to every
|
|
23
|
+
# real saved doc at Save-as-doc time -- these are App-only methods, not
|
|
24
|
+
# part of DisplayDSL, so any real saved doc crashes instance_eval without
|
|
25
|
+
# them. No-ops here: the Writer only needs the component tree, not
|
|
26
|
+
# theme/layout state (org format doesn't represent either).
|
|
27
|
+
#
|
|
28
|
+
# Everything ELSE the DSL can say -- App's whole interactive vocabulary
|
|
29
|
+
# (radio_group, text_field, form, ...), charts, layout shells -- is
|
|
30
|
+
# absorbed by #method_missing as an UnsupportedCall placeholder rather
|
|
31
|
+
# than raising, so a Save-as-Org can never die on a call this context
|
|
32
|
+
# does not define. That is a claim about UNDEFINED calls only: a
|
|
33
|
+
# DisplayDSL method that this context's half-set-up state trips over
|
|
34
|
+
# never reaches method_missing at all (see @_state in #initialize for the
|
|
35
|
+
# one that did), so new state DisplayDSL starts depending on has to be
|
|
36
|
+
# set up here explicitly.
|
|
37
|
+
class RecordingContext
|
|
38
|
+
include StreamWeaver::DisplayDSL
|
|
39
|
+
|
|
40
|
+
# Stand-in for a DSL call this context builds no real component for.
|
|
41
|
+
# It records WHAT was called and nothing else -- deciding what a saved
|
|
42
|
+
# .org should do with it is the Writer's job (Writer::INTERACTIVE_CALLS).
|
|
43
|
+
UnsupportedCall = Struct.new(:name)
|
|
44
|
+
|
|
45
|
+
attr_accessor :components
|
|
46
|
+
attr_reader :state, :render_state
|
|
47
|
+
|
|
48
|
+
def initialize
|
|
49
|
+
@components = []
|
|
50
|
+
# @_state is the ivar DisplayDSL's own stateful components write to
|
|
51
|
+
# directly -- `select` does `@_state[key] = ...` with no nil guard on
|
|
52
|
+
# the write path (display_dsl.rb), so leaving it nil turned any doc
|
|
53
|
+
# containing a select into a NoMethodError on nil at save time. It is
|
|
54
|
+
# the same throwaway hash as #state here: this context records a
|
|
55
|
+
# component tree and never renders, so nothing reads either back.
|
|
56
|
+
@state = @_state = {}
|
|
57
|
+
@render_state = StreamWeaver::App::RenderState.new
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def use_theme(_name); end
|
|
61
|
+
def use_layout(_name); end
|
|
62
|
+
|
|
63
|
+
# Two things the code below can't say for itself:
|
|
64
|
+
#
|
|
65
|
+
# The block is deliberately NOT evaluated. For an interactive container
|
|
66
|
+
# (`form do ... end`) its children are part of the control the Writer
|
|
67
|
+
# drops; for anything else the whole statement -- block included -- is
|
|
68
|
+
# what Writer#raw_passthrough recovers verbatim from the source.
|
|
69
|
+
#
|
|
70
|
+
# `to_*` keeps raising, because it's Ruby's implicit-conversion
|
|
71
|
+
# protocol (to_ary/to_str/to_hash/to_proc): answering one of those with
|
|
72
|
+
# a nil placeholder turns a harmless duck-type probe into a TypeError
|
|
73
|
+
# somewhere far away.
|
|
74
|
+
def method_missing(name, *_args, **_kwargs, &_block)
|
|
75
|
+
return super if name.to_s.start_with?("to_")
|
|
76
|
+
|
|
77
|
+
components << UnsupportedCall.new(name)
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def respond_to_missing?(name, _include_private = false)
|
|
82
|
+
!name.to_s.start_with?("to_")
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|