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,853 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'cgi'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'socket'
|
|
6
|
+
require 'sinatra/base'
|
|
7
|
+
require 'stream_weaver/app'
|
|
8
|
+
require 'stream_weaver/views'
|
|
9
|
+
require 'stream_weaver/adapter/alpinejs'
|
|
10
|
+
require 'stream_weaver/canvas/doc_store'
|
|
11
|
+
require 'stream_weaver/canvas/doc_roots'
|
|
12
|
+
require 'stream_weaver/canvas/gist_store'
|
|
13
|
+
require 'stream_weaver/canvas/gist_publisher'
|
|
14
|
+
require 'stream_weaver/canvas/gist_save_handler'
|
|
15
|
+
require 'stream_weaver/canvas/save_doc_widget'
|
|
16
|
+
require 'stream_weaver/page_shell'
|
|
17
|
+
require 'stream_weaver/export/html_exporter'
|
|
18
|
+
require 'stream_weaver/org/writer'
|
|
19
|
+
require 'stream_weaver/org/reader'
|
|
20
|
+
|
|
21
|
+
module StreamWeaver
|
|
22
|
+
module Canvas
|
|
23
|
+
class Reader < Sinatra::Base
|
|
24
|
+
include GistSaveHandler
|
|
25
|
+
|
|
26
|
+
class NoFilesError < StandardError; end
|
|
27
|
+
|
|
28
|
+
class FileList
|
|
29
|
+
attr_reader :files, :history_roots, :labels
|
|
30
|
+
|
|
31
|
+
# `labels` is {root_path => display_label} for multi-repo discovery
|
|
32
|
+
# (stream_weaver-iugu). Optional and defaulted so the single-root and
|
|
33
|
+
# history-root callers that predate it are untouched -- a directory
|
|
34
|
+
# with no label falls back to its own basename, which is exactly what
|
|
35
|
+
# the sidebar showed before labels existed.
|
|
36
|
+
def self.build(args, history_roots: [], labels: {})
|
|
37
|
+
files = args.flat_map do |arg|
|
|
38
|
+
if File.directory?(arg)
|
|
39
|
+
Dir.glob(File.join(arg, '*.{rb,org}')).sort
|
|
40
|
+
elsif File.exist?(arg) && arg.end_with?('.rb', '.org')
|
|
41
|
+
[File.expand_path(arg)]
|
|
42
|
+
else
|
|
43
|
+
[]
|
|
44
|
+
end
|
|
45
|
+
end.uniq
|
|
46
|
+
|
|
47
|
+
raise NoFilesError, "No .rb or .org files found in: #{args.join(', ')}" if files.empty?
|
|
48
|
+
|
|
49
|
+
new(files, args: args, history_roots: history_roots, labels: labels)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# A list with no files, still remembering where it was looking.
|
|
53
|
+
# Deliberately not reachable from .build (which raises NoFilesError
|
|
54
|
+
# instead, so `canvas-read nosuchdir` still fails loudly at boot) --
|
|
55
|
+
# this is only for a list that HAD files and lost its last one while
|
|
56
|
+
# the reader was running (stream_weaver-uvaj).
|
|
57
|
+
def self.empty(args, history_roots: [], labels: {})
|
|
58
|
+
new([], args: args, history_roots: history_roots, labels: labels)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def initialize(files, args:, history_roots: [], labels: {})
|
|
62
|
+
@files = files
|
|
63
|
+
@args = args
|
|
64
|
+
@history_roots = history_roots.map { |p| File.expand_path(p) }
|
|
65
|
+
@labels = labels.each_with_object({}) { |(root, label), out| out[File.expand_path(root)] = label }
|
|
66
|
+
@dir_mtimes = snapshot_dir_mtimes
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# True when a source directory's mtime has moved since this list was
|
|
70
|
+
# built (stream_weaver-gnj8) -- a directory's own mtime bumps when
|
|
71
|
+
# an entry is added or removed inside it (a Save-as-doc write, a git
|
|
72
|
+
# pull, a direct edit from any process), which is the only kind of
|
|
73
|
+
# change that affects what FILES are in this list. An existing
|
|
74
|
+
# file's own content changing does NOT bump its directory's mtime
|
|
75
|
+
# and doesn't need to: GET / already re-reads file content fresh on
|
|
76
|
+
# every request regardless of this cache, so only the *set* of
|
|
77
|
+
# files can ever go stale here.
|
|
78
|
+
def stale?
|
|
79
|
+
@dir_mtimes.any? { |dir, snapshot| current_mtime(dir) != snapshot }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Rebuilds from the same source args if stale, otherwise returns
|
|
83
|
+
# self unchanged -- the common (nothing changed) case costs one
|
|
84
|
+
# stat() per source directory and nothing else.
|
|
85
|
+
def rebuild_if_stale
|
|
86
|
+
return self unless stale?
|
|
87
|
+
|
|
88
|
+
self.class.build(@args, history_roots: @history_roots, labels: @labels)
|
|
89
|
+
rescue NoFilesError
|
|
90
|
+
# Two very different situations land here and they want opposite
|
|
91
|
+
# answers:
|
|
92
|
+
#
|
|
93
|
+
# * Every source directory VANISHED (deleted, unmounted, or -- in
|
|
94
|
+
# specs -- a Dir.mktmpdir block that already exited). Keep
|
|
95
|
+
# serving the last-known list rather than raising: a stale list
|
|
96
|
+
# is still useful navigation, a crashed request is not, and the
|
|
97
|
+
# paths may well come back. Matches browse_entries/
|
|
98
|
+
# resolve_browse_dir's existing philosophy elsewhere in this
|
|
99
|
+
# class -- filesystem drift degrades gracefully, never 500s.
|
|
100
|
+
# * The directories are all still THERE and simply hold no docs
|
|
101
|
+
# any more -- which is exactly what deleting the last one
|
|
102
|
+
# produces (stream_weaver-uvaj). Here the list really is empty,
|
|
103
|
+
# and answering with the stale one leaves the file you just
|
|
104
|
+
# deleted in the rail behind a link that 500s on File.read.
|
|
105
|
+
if @args.any? { |arg| File.exist?(arg) }
|
|
106
|
+
self.class.empty(@args, history_roots: @history_roots, labels: @labels)
|
|
107
|
+
else
|
|
108
|
+
self
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def groups
|
|
113
|
+
@files.group_by { |f| File.dirname(f) }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def indexed_groups
|
|
117
|
+
@indexed_groups ||= @files.each_with_index.group_by { |f, _| File.dirname(f) }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# True when dir is the same as a history root or sits underneath one.
|
|
121
|
+
def history_dir?(dir)
|
|
122
|
+
@history_roots.any? { |r| dir == r || dir.start_with?(r + '/') }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Display name for a docs directory: its registered label when it has
|
|
126
|
+
# one (the repo it belongs to), otherwise its own basename.
|
|
127
|
+
def label_for(dir)
|
|
128
|
+
@labels[dir] || @labels[File.expand_path(dir)] || File.basename(dir)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Label for an arbitrary root path, compared canonically -- the host
|
|
132
|
+
# repo's docs root arrives from Reader.repo_docs_root, which resolves
|
|
133
|
+
# via Dir.pwd and can differ from the registered spelling by a
|
|
134
|
+
# symlink (/tmp vs /private/tmp on macOS). nil when no group matches,
|
|
135
|
+
# which is what tells the filter to fall back rather than select a
|
|
136
|
+
# group that isn't there.
|
|
137
|
+
def label_for_root(path)
|
|
138
|
+
return nil unless path
|
|
139
|
+
|
|
140
|
+
expanded = File.expand_path(path)
|
|
141
|
+
return @labels[expanded] if @labels.key?(expanded)
|
|
142
|
+
|
|
143
|
+
key = DocRoots.canonical(expanded)
|
|
144
|
+
hit = @labels.keys.find { |k| DocRoots.canonical(k) == key }
|
|
145
|
+
hit && @labels[hit]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Sidebar splits: docs (explicit args) render above, history (auto-collected
|
|
149
|
+
# snapshots from ~/.streamweaver/history/) renders below collapsed.
|
|
150
|
+
#
|
|
151
|
+
# `repo:` narrows to one label (stream_weaver-iugu). It filters which
|
|
152
|
+
# GROUPS render, never the file indices inside them -- ?file=N stays a
|
|
153
|
+
# position in the whole list, so a filtered sidebar and an unfiltered
|
|
154
|
+
# one address the same files and no link changes meaning when the
|
|
155
|
+
# filter does.
|
|
156
|
+
def docs_groups(repo: nil)
|
|
157
|
+
groups = indexed_groups.reject { |dir, _| history_dir?(dir) }
|
|
158
|
+
return groups unless repo
|
|
159
|
+
|
|
160
|
+
groups.select { |dir, _| label_for(dir) == repo }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Labels of every docs group with at least one file, in sidebar order.
|
|
164
|
+
def repo_labels
|
|
165
|
+
indexed_groups.keys.reject { |dir| history_dir?(dir) }.map { |dir| label_for(dir) }.uniq
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def history_groups
|
|
169
|
+
indexed_groups.select { |dir, _| history_dir?(dir) }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Array#[] wraps on negative indices and nil.to_i is 0 -- neither is a
|
|
173
|
+
# valid file reference, so this refuses both centrally rather than
|
|
174
|
+
# every caller re-deriving the same guard (and GET / not bothering to).
|
|
175
|
+
def at(index)
|
|
176
|
+
@files[index] if index.is_a?(Integer) && index >= 0
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def size
|
|
180
|
+
@files.size
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
private
|
|
184
|
+
|
|
185
|
+
def snapshot_dir_mtimes
|
|
186
|
+
@args.each_with_object({}) do |arg, snapshot|
|
|
187
|
+
snapshot[arg] = current_mtime(arg) if File.directory?(arg)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# A directory that vanished since boot reads as nil, which never
|
|
192
|
+
# equals a real mtime -- stale? then forces a rebuild attempt
|
|
193
|
+
# rather than silently trusting a cache that points at nothing.
|
|
194
|
+
# If genuinely nothing is left, .build's own NoFilesError still
|
|
195
|
+
# fires the way it always has.
|
|
196
|
+
def current_mtime(dir)
|
|
197
|
+
File.mtime(dir)
|
|
198
|
+
rescue SystemCallError
|
|
199
|
+
nil
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
MERMAID_ZOOM_JS = File.read(File.expand_path('../assets/js/sw-mermaid-zoom.js', __dir__))
|
|
204
|
+
private_constant :MERMAID_ZOOM_JS
|
|
205
|
+
|
|
206
|
+
configure do
|
|
207
|
+
set :views, File.expand_path('../views/canvas', __dir__)
|
|
208
|
+
set :bind, '127.0.0.1'
|
|
209
|
+
set :server, :puma
|
|
210
|
+
set :logging, false
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# What Reader.render_doc hands the layout template: the rendered HTML
|
|
214
|
+
# plus the theme/layout/CSS the DSL declared for itself, which the shell
|
|
215
|
+
# needs for the <body> class and the user-CSS <style> tags.
|
|
216
|
+
Doc = Struct.new(:html, :theme, :layout, :inline_stylesheets, keyword_init: true)
|
|
217
|
+
|
|
218
|
+
class << self
|
|
219
|
+
attr_reader :default_theme, :default_layout
|
|
220
|
+
|
|
221
|
+
# Returns the configured FileList, rebuilding it first if it's gone
|
|
222
|
+
# stale (stream_weaver-gnj8 -- see FileList#stale?). nil-safe: a
|
|
223
|
+
# future Browse-only boot mode with no configured files at all
|
|
224
|
+
# would leave @file_list nil, which has nothing to rebuild.
|
|
225
|
+
# Concurrent requests both detecting staleness and both rebuilding
|
|
226
|
+
# is possible under Puma's threaded server -- harmless (each
|
|
227
|
+
# rebuild produces an equivalent, immutable FileList; worst case is
|
|
228
|
+
# redundant work, never corruption), so not worth a mutex for a
|
|
229
|
+
# single-user local dev tool.
|
|
230
|
+
def file_list
|
|
231
|
+
@file_list = @file_list.rebuild_if_stale if @file_list
|
|
232
|
+
@file_list
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def configure_files!(list)
|
|
236
|
+
@file_list = list
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Fallback theme/layout for files that don't declare their own
|
|
240
|
+
# (`canvas-read --theme=doc --layout=fluid`). Precedence is
|
|
241
|
+
# DSL use_theme > CLI flag > :default/:fluid.
|
|
242
|
+
def configure_defaults!(theme: nil, layout: nil)
|
|
243
|
+
@default_theme = theme&.to_sym
|
|
244
|
+
@default_layout = layout&.to_sym
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# render_doc and GET /export both need these; a single source of
|
|
248
|
+
# truth is what makes "the download matches what's on screen" true
|
|
249
|
+
# rather than aspirational -- two independent `|| :fluid`s could
|
|
250
|
+
# silently drift.
|
|
251
|
+
def fallback_theme
|
|
252
|
+
default_theme || :default
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def fallback_layout
|
|
256
|
+
default_layout || :fluid
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def find_available_port(start = 4800)
|
|
260
|
+
port = start
|
|
261
|
+
loop do
|
|
262
|
+
TCPServer.new('127.0.0.1', port).close
|
|
263
|
+
return port
|
|
264
|
+
rescue Errno::EADDRINUSE
|
|
265
|
+
port += 1
|
|
266
|
+
raise "No available port found starting from #{start}" if port > start + 100
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Absolute path of the current repo's docs root, or nil outside a
|
|
271
|
+
# git repo. Same resolution DocStore.path itself would use for the
|
|
272
|
+
# "in a repo" branch -- kept as a named shortcut (not stored state)
|
|
273
|
+
# for the Browse view's quick-jump link (stream_weaver-rdh).
|
|
274
|
+
def repo_docs_root
|
|
275
|
+
root = StreamWeaver::Canvas::DocStore.git_root(Dir.pwd)
|
|
276
|
+
root && File.join(root, StreamWeaver::Canvas::DocStore::DOCS_SUBPATH)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# The only two docs roots this process is allowed to delete from
|
|
280
|
+
# (stream_weaver-uvaj), canonicalized. Both resolve from THIS
|
|
281
|
+
# process's own state -- the repo canvas-read was launched in, and
|
|
282
|
+
# the global store -- never from what an attaching client claims,
|
|
283
|
+
# and never from the peer repos DocRoots surfaces for reading. A
|
|
284
|
+
# peer repo's docs are readable here and permanently undeletable:
|
|
285
|
+
# narrower blast radius wins over full reach (design doc, Delete).
|
|
286
|
+
def deletable_roots
|
|
287
|
+
[repo_docs_root, DocStore::DEFAULT_ROOT].compact.filter_map { |root| canonical_file(root) }
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Symlink- and `..`-resolved absolute path, or nil for anything that
|
|
291
|
+
# doesn't resolve to something on disk. One helper for every
|
|
292
|
+
# comparison below, so the delete guard and the "is this the file we
|
|
293
|
+
# just deleted?" lookup can't drift apart on how they canonicalize.
|
|
294
|
+
# expand_path first, for `~` (and its ArgumentError on `~nosuchuser`).
|
|
295
|
+
def canonical_file(path)
|
|
296
|
+
File.realpath(File.expand_path(path.to_s))
|
|
297
|
+
rescue SystemCallError, ArgumentError, TypeError
|
|
298
|
+
nil
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# True when a sidebar group's directory is one of those two roots --
|
|
302
|
+
# what decides whether its rows render a delete control at all. The
|
|
303
|
+
# UI just stops you asking for something POST /delete-doc would
|
|
304
|
+
# refuse anyway; the server-side check below is the actual boundary.
|
|
305
|
+
def deletable_dir?(dir)
|
|
306
|
+
real = canonical_file(dir)
|
|
307
|
+
!real.nil? && deletable_roots.include?(real)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# The canonical absolute path when `raw` names a doc file sitting
|
|
311
|
+
# DIRECTLY in one of the deletable roots; nil for anything else.
|
|
312
|
+
#
|
|
313
|
+
# The containment test is equality on the canonicalized direct
|
|
314
|
+
# PARENT, not `start_with?` against the root. A prefix match would
|
|
315
|
+
# happily accept a sibling directory whose name merely begins with
|
|
316
|
+
# the root's (`..._canvas_evil/x.rb`), and would accept arbitrarily
|
|
317
|
+
# nested subdirectories the sidebar never lists. realpath resolves
|
|
318
|
+
# every `..` and symlink first, so neither a traversal
|
|
319
|
+
# (`<root>/../../../etc/x.rb`) nor a symlink planted inside the root
|
|
320
|
+
# pointing out of it survives the comparison -- the link resolves to
|
|
321
|
+
# its target, whose parent is not the root.
|
|
322
|
+
#
|
|
323
|
+
# Deliberately NOT hardened against a symlink swapped in between
|
|
324
|
+
# this check and the File.delete that follows: single-user local
|
|
325
|
+
# tool, explicitly out of the threat model (design doc, Explicitly
|
|
326
|
+
# declined).
|
|
327
|
+
def deletable_path(raw)
|
|
328
|
+
return nil unless raw.is_a?(String) && !raw.empty? && !raw.include?("\0")
|
|
329
|
+
return nil unless raw.end_with?('.rb', '.org')
|
|
330
|
+
|
|
331
|
+
real = canonical_file(raw)
|
|
332
|
+
return nil unless real && File.file?(real)
|
|
333
|
+
|
|
334
|
+
deletable_roots.include?(File.dirname(real)) ? real : nil
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Which `?file=N` to land on after a delete, read off the REBUILT
|
|
338
|
+
# list (file_list re-globs itself once the directory mtime moves).
|
|
339
|
+
# `deleted` is the removed file's index in the pre-delete list,
|
|
340
|
+
# `current` the index that was open in the browser.
|
|
341
|
+
#
|
|
342
|
+
# Deleting the open doc keeps the same position, because the next
|
|
343
|
+
# surviving doc slides into it -- clamped to the last index, which
|
|
344
|
+
# is what "step back one" means when the deleted doc was last. A doc
|
|
345
|
+
# deleted from ABOVE the open one shifts it down by one, so the same
|
|
346
|
+
# file stays on screen instead of the rail silently jumping. nil
|
|
347
|
+
# means nothing is left to open.
|
|
348
|
+
def index_after_delete(deleted, current)
|
|
349
|
+
size = file_list&.size.to_i
|
|
350
|
+
return nil if size.zero?
|
|
351
|
+
|
|
352
|
+
current = nil unless current.is_a?(Integer) && current >= 0
|
|
353
|
+
return 0 unless current
|
|
354
|
+
|
|
355
|
+
adjusted = deleted && current > deleted ? current - 1 : current
|
|
356
|
+
[adjusted, size - 1].min
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Which repo group the sidebar shows, from the `?repo=` param
|
|
360
|
+
# (stream_weaver-iugu). Returns a label, or nil meaning "all."
|
|
361
|
+
#
|
|
362
|
+
# - `?repo=all` (or any label with no matching group) is the explicit
|
|
363
|
+
# clear. An unknown label falling back to "all" rather than 404ing
|
|
364
|
+
# is deliberate: a bookmarked filter whose repo has since been
|
|
365
|
+
# deleted should still show you your docs.
|
|
366
|
+
# - No param at all defaults to the repo the reader process was
|
|
367
|
+
# launched from, so `cd myrepo && canvas-read` opens on myrepo's
|
|
368
|
+
# docs. Falls back to the global store, then to "all" -- launched
|
|
369
|
+
# from a repo with no docs of its own, showing everything beats
|
|
370
|
+
# showing an empty rail.
|
|
371
|
+
def resolve_repo_filter(raw, list)
|
|
372
|
+
return nil unless list
|
|
373
|
+
|
|
374
|
+
value = raw.to_s
|
|
375
|
+
return nil if value == 'all'
|
|
376
|
+
return list.repo_labels.include?(value) ? value : nil unless value.empty?
|
|
377
|
+
|
|
378
|
+
default_repo_filter(list)
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def default_repo_filter(list)
|
|
382
|
+
labels = list.repo_labels
|
|
383
|
+
# The host repo has no label of its own to compare against -- it
|
|
384
|
+
# arrives as a path -- so it's matched by path. The global store
|
|
385
|
+
# is matched by its label instead, since that label is a constant
|
|
386
|
+
# DocRoots assigns and is the same thing `?repo=` addresses.
|
|
387
|
+
host = list.label_for_root(repo_docs_root)
|
|
388
|
+
return host if host && labels.include?(host)
|
|
389
|
+
|
|
390
|
+
DocRoots::GLOBAL_LABEL if labels.include?(DocRoots::GLOBAL_LABEL)
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# {dirs:, files:} immediately under `dir` -- one level, not recursive
|
|
394
|
+
# (Browse navigates by clicking in, not by a pre-walked tree). Dotfiles
|
|
395
|
+
# excluded (matches normal file-browser expectations; a .git directory
|
|
396
|
+
# in the listing is noise, never something you'd navigate into here).
|
|
397
|
+
# Swallows ENOENT/EACCES rather than raising: a stale bookmark or a
|
|
398
|
+
# permission-denied directory should render an empty listing, not a 500.
|
|
399
|
+
def browse_entries(dir)
|
|
400
|
+
entries = Dir.children(dir).reject { |e| e.start_with?('.') }.sort
|
|
401
|
+
# partition, not two independent #select calls: a directory named
|
|
402
|
+
# "bundle.rb" (rare, but real -- generator fixtures do this) would
|
|
403
|
+
# otherwise satisfy both the dirs and files predicates and get
|
|
404
|
+
# listed twice, the second listing a dead link (/open 404s on it).
|
|
405
|
+
dirs, rest = entries.partition { |e| File.directory?(File.join(dir, e)) }
|
|
406
|
+
{ dirs: dirs, files: rest.select { |e| e.end_with?('.rb', '.org') } }
|
|
407
|
+
rescue SystemCallError
|
|
408
|
+
# Broader than Errno::ENOENT/EACCES alone: a TCC-protected macOS
|
|
409
|
+
# directory (~/Library/Mail, ~/Documents without Full Disk Access)
|
|
410
|
+
# raises Errno::EPERM, not EACCES, and $HOME -- Browse's own
|
|
411
|
+
# default landing directory -- routinely contains one. A symlink
|
|
412
|
+
# loop raises Errno::ELOOP. All of them mean the same thing here:
|
|
413
|
+
# show an empty listing, not a 500.
|
|
414
|
+
{ dirs: [], files: [] }
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# Expands and validates a Browse `dir` param, falling back to $HOME
|
|
418
|
+
# for anything blank, relative-and-missing, or not actually a
|
|
419
|
+
# directory -- Browse always has *somewhere* valid to show rather
|
|
420
|
+
# than erroring on a stale/hand-edited query string. The rescue
|
|
421
|
+
# covers what File.expand_path itself can raise on bad input
|
|
422
|
+
# (?dir=~nosuchuser, a null byte, a non-String param from
|
|
423
|
+
# ?dir[]=x) -- all real, reachable ways to reach this from a
|
|
424
|
+
# browser address bar or a stale link, not just theoretical.
|
|
425
|
+
def resolve_browse_dir(raw)
|
|
426
|
+
expanded = raw && !raw.to_s.empty? && (File.expand_path(raw.to_s) rescue nil)
|
|
427
|
+
expanded && File.directory?(expanded) ? expanded : Dir.home
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
# 127.0.0.1-binding stops a remote client, but not a browser already
|
|
432
|
+
# on this machine: any page open in any tab can issue a cross-origin
|
|
433
|
+
# GET here with no CSRF token required (an <img src="http://127.0.0.1:
|
|
434
|
+
# 4800/open?path=...">, a bare <a>, a form) -- same-origin policy
|
|
435
|
+
# blocks that page from READING the response, not from sending the
|
|
436
|
+
# request. That distinction didn't matter much when the worst case
|
|
437
|
+
# was an unwanted render; it matters a great deal now that /open
|
|
438
|
+
# (stream_weaver-rdh) *evaluates* the .rb file it opens.
|
|
439
|
+
#
|
|
440
|
+
# Two independent checks, because either alone has a gap:
|
|
441
|
+
# - Host: blocks the common drive-by case outright. Beaten by DNS
|
|
442
|
+
# rebinding, where an attacker's domain re-resolves to 127.0.0.1
|
|
443
|
+
# mid-session, making the request genuinely same-origin by the time
|
|
444
|
+
# it arrives.
|
|
445
|
+
# - Sec-Fetch-Site: set by the browser itself from the *page's own*
|
|
446
|
+
# origin, not spoofable by page JS, so it still reads "cross-site"
|
|
447
|
+
# after a rebind. Older browsers omit the header entirely; failing
|
|
448
|
+
# open on absence (rather than blocking) is deliberate -- this is a
|
|
449
|
+
# single-user local dev tool where the primary path is a modern
|
|
450
|
+
# browser navigating here directly, and false positives there would
|
|
451
|
+
# be worse than the residual risk from a browser old enough to omit
|
|
452
|
+
# Fetch Metadata.
|
|
453
|
+
before do
|
|
454
|
+
halt 403, 'Forbidden' unless %w[127.0.0.1 localhost].include?(request.host)
|
|
455
|
+
|
|
456
|
+
site = request.env['HTTP_SEC_FETCH_SITE']
|
|
457
|
+
halt 403, 'Forbidden' if site && !%w[same-origin none].include?(site)
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
get '/health' do
|
|
461
|
+
'ok'
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
get '/' do
|
|
465
|
+
return redirect '/?file=0' unless params.key?('file')
|
|
466
|
+
|
|
467
|
+
index = params[:file].to_i
|
|
468
|
+
list = self.class.file_list
|
|
469
|
+
path = list&.at(index)
|
|
470
|
+
# "Nothing left to open" is a real state now (stream_weaver-uvaj:
|
|
471
|
+
# you just deleted the last doc), not a bad request -- answering a
|
|
472
|
+
# successful delete with a 404 page would be a lie about what
|
|
473
|
+
# happened. Renders the same no-doc-open placeholder a bare Browse
|
|
474
|
+
# already uses.
|
|
475
|
+
return render_no_doc(list) if path.nil? && list && list.size.zero?
|
|
476
|
+
halt 404, 'File not found' unless path
|
|
477
|
+
|
|
478
|
+
dsl = File.read(path)
|
|
479
|
+
@doc = Reader.render_doc(dsl, path: path)
|
|
480
|
+
@file_list = list
|
|
481
|
+
@current_index = index
|
|
482
|
+
@current_file = path
|
|
483
|
+
# @repo_filter is the resolved label the sidebar renders; @repo_param
|
|
484
|
+
# is the raw param, carried through every in-sidebar link so an
|
|
485
|
+
# explicit filter survives navigation instead of snapping back to the
|
|
486
|
+
# host-repo default on the next click.
|
|
487
|
+
@repo_filter = self.class.resolve_repo_filter(params[:repo], list)
|
|
488
|
+
@repo_param = params[:repo].to_s.empty? ? nil : params[:repo].to_s
|
|
489
|
+
@mermaid_zoom_js = MERMAID_ZOOM_JS
|
|
490
|
+
erb :reader_layout, layout: false
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
# Live filesystem browse (stream_weaver-rdh) -- the thing actually
|
|
494
|
+
# missing before this: canvas-read already renders any file/dir handed
|
|
495
|
+
# to it as a CLI arg, but once running, you're stuck with what you
|
|
496
|
+
# started it with. No index, no registered locations: browsing IS the
|
|
497
|
+
# discovery, computed fresh on every request. NOT the same trust
|
|
498
|
+
# boundary as the CLI args this already accepts, despite first
|
|
499
|
+
# appearances -- a CLI arg is the user naming a file once, with
|
|
500
|
+
# intent; an HTTP GET is reachable from any tab in the user's
|
|
501
|
+
# browser, and /open below evaluates what it opens. See the `before`
|
|
502
|
+
# filter above for why that gap is actually closed.
|
|
503
|
+
#
|
|
504
|
+
# No `?file=N` here -- Browse's sidebar replaces the file-list view
|
|
505
|
+
# entirely rather than adding to it, so there's no index into anything
|
|
506
|
+
# to render disabled/enabled Prev/Next against. @current_index stays
|
|
507
|
+
# unset, which the layout's nav block treats as "no file open."
|
|
508
|
+
get '/browse' do
|
|
509
|
+
set_browse_ivars(self.class.resolve_browse_dir(params[:dir]))
|
|
510
|
+
@file_list = self.class.file_list
|
|
511
|
+
@mermaid_zoom_js = MERMAID_ZOOM_JS
|
|
512
|
+
erb :reader_layout, layout: false
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
# Renders one file found via Browse, independent of the configured
|
|
516
|
+
# FileList/docs_groups/history_groups -- this is what makes Browse not
|
|
517
|
+
# need an index: viewing a browsed file was never routed through a
|
|
518
|
+
# precomputed list to begin with. Sidebar stays in Browse mode (the
|
|
519
|
+
# opened file's own directory), so browsing feels continuous rather
|
|
520
|
+
# than dropping back to the original file list.
|
|
521
|
+
get '/open' do
|
|
522
|
+
path = File.expand_path(params[:path].to_s)
|
|
523
|
+
halt 404, 'File not found' unless File.file?(path) && path.end_with?('.rb', '.org')
|
|
524
|
+
|
|
525
|
+
dsl = begin
|
|
526
|
+
File.read(path)
|
|
527
|
+
rescue SystemCallError
|
|
528
|
+
halt 404, 'File not found'
|
|
529
|
+
end
|
|
530
|
+
@doc = Reader.render_doc(dsl, path: path)
|
|
531
|
+
@current_file = path
|
|
532
|
+
set_browse_ivars(File.dirname(path))
|
|
533
|
+
@file_list = self.class.file_list
|
|
534
|
+
@mermaid_zoom_js = MERMAID_ZOOM_JS
|
|
535
|
+
erb :reader_layout, layout: false
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
# Download the currently-viewed file as a standalone HTML document.
|
|
539
|
+
# Same ?file=N index convention as GET /, and the same theme/layout
|
|
540
|
+
# fallbacks render_doc uses (fallback_theme/fallback_layout), so the
|
|
541
|
+
# download matches what's on screen.
|
|
542
|
+
#
|
|
543
|
+
# Failures answer text/plain with a status, never a partial document:
|
|
544
|
+
# a half-written .html landing in ~/Downloads looks like a success.
|
|
545
|
+
# content_type is set to :text up front for exactly that reason --
|
|
546
|
+
# halt'ing after a `content_type :html` would still serve the error
|
|
547
|
+
# body as HTML. The export link itself must never carry a `download`
|
|
548
|
+
# attribute, or the browser saves an error body as a mystery file
|
|
549
|
+
# instead of showing it (see reader_layout.erb).
|
|
550
|
+
# ?offline=1 inlines mermaid's own library instead of referencing its
|
|
551
|
+
# CDN (stream_weaver-dnq), so a diagram renders in a viewer whose CSP
|
|
552
|
+
# blocks external scripts entirely (SharePoint's HTML preview, etc.).
|
|
553
|
+
# Needs network access at export time; a fetch failure there is a 502
|
|
554
|
+
# (this server tried an upstream and it failed), distinct from the
|
|
555
|
+
# 422s below for bad input.
|
|
556
|
+
get '/export' do
|
|
557
|
+
content_type :text
|
|
558
|
+
path = self.class.file_list&.at(params[:file].to_i)
|
|
559
|
+
halt 404, 'File not found' unless path
|
|
560
|
+
|
|
561
|
+
html = begin
|
|
562
|
+
StreamWeaver::Export::HtmlExporter.from_dsl_file(
|
|
563
|
+
path,
|
|
564
|
+
theme: self.class.fallback_theme,
|
|
565
|
+
layout: self.class.fallback_layout
|
|
566
|
+
).to_html(offline: params[:offline] == '1')
|
|
567
|
+
rescue StreamWeaver::Export::InvalidDslError => e
|
|
568
|
+
halt 422, "Export failed: #{e.message}"
|
|
569
|
+
rescue StreamWeaver::Export::OfflineAssetError => e
|
|
570
|
+
halt 502, "Export failed: #{e.message}"
|
|
571
|
+
rescue ScriptError, StandardError => e
|
|
572
|
+
# A DSL that fails to eval is bad input, not an exporter failure --
|
|
573
|
+
# same 422 GET / gives the equivalent case (its red error box).
|
|
574
|
+
halt 422, "Export failed: #{e.message}"
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
content_type :html
|
|
578
|
+
headers['Content-Disposition'] =
|
|
579
|
+
%(attachment; filename="#{StreamWeaver::Export::HtmlExporter.export_filename(path)}")
|
|
580
|
+
html
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
# Promote a history snapshot to a persistent canvas doc (Tier 2).
|
|
584
|
+
# Body: {"file": <integer-index>, "name": "<doc-name>", "format": "rb"|"org"}.
|
|
585
|
+
# format defaults to "rb". Mirrors BridgeServer's /canvas/:name/save-doc
|
|
586
|
+
# contract: 200 on success, 422 on bad index / bad name / bad format,
|
|
587
|
+
# 404 when no list configured, 500 otherwise.
|
|
588
|
+
post '/save-doc' do
|
|
589
|
+
content_type :json
|
|
590
|
+
list = self.class.file_list
|
|
591
|
+
halt 404, { ok: false, error: 'No file list configured' }.to_json unless list
|
|
592
|
+
|
|
593
|
+
body = JSON.parse(request.body.read, symbolize_names: true) rescue {}
|
|
594
|
+
index = body[:file]
|
|
595
|
+
name = body[:name]
|
|
596
|
+
format = body[:format] || 'rb'
|
|
597
|
+
halt 422, { ok: false, error: "unrecognized format: #{format.inspect}" }.to_json unless %w[rb org].include?(format)
|
|
598
|
+
|
|
599
|
+
# FileList#at already refuses non-Integer/negative indices; File.exist?
|
|
600
|
+
# covers the case an in-range index still points at a file that's
|
|
601
|
+
# since been deleted out from under us.
|
|
602
|
+
file_path = list.at(index)
|
|
603
|
+
unless file_path && File.exist?(file_path)
|
|
604
|
+
halt 422, { ok: false, error: "File index out of range: #{index.inspect}" }.to_json
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
# No DocStore.dsl_with_metadata call here, unlike BridgeServer's
|
|
608
|
+
# save-doc route: this promotes a history snapshot, and snapshots are
|
|
609
|
+
# written by `canvas-push` (CLI.record_push_history), which never sees
|
|
610
|
+
# the bridge session's theme/layout. There is nothing to inject.
|
|
611
|
+
# Snapshots whose DSL doesn't declare `use_theme` itself keep rendering
|
|
612
|
+
# with canvas-read's default -- accepted limitation (stream_weaver-csf).
|
|
613
|
+
dsl = File.read(file_path)
|
|
614
|
+
|
|
615
|
+
# Gist is a third destination, not a third format (mirrors
|
|
616
|
+
# bridge_server.rb's equivalent guard). No theme/layout to carry
|
|
617
|
+
# into the gist's .rb file either, for the same reason the org/rb
|
|
618
|
+
# branches above carry none: the reader has no live session.
|
|
619
|
+
return handle_gist_save(dsl, name) if body[:scope] == 'gist'
|
|
620
|
+
|
|
621
|
+
# The Save-as-doc toggle (stream_weaver-j3b3). No source_dir is passed
|
|
622
|
+
# to DocStore.save below -- unlike BridgeServer, the reader has no
|
|
623
|
+
# live session to carry one, so "This repo" keeps resolving the same
|
|
624
|
+
# way it always has: DocStore's own auto-detection off the reader
|
|
625
|
+
# process's cwd (see reader_layout.erb's SaveDocWidget.render call).
|
|
626
|
+
# Computed here, after the gist escape hatch above, rather than
|
|
627
|
+
# collapsed earlier -- unused on the gist path, so there's nothing
|
|
628
|
+
# to compute early for it.
|
|
629
|
+
scope = body[:scope] == 'global' ? :global : :repo
|
|
630
|
+
|
|
631
|
+
rescue_save_errors do
|
|
632
|
+
if format == 'org'
|
|
633
|
+
writer = StreamWeaver::Org::Writer.new(dsl)
|
|
634
|
+
org_text = writer.call
|
|
635
|
+
# Strip any .rb/.org the user already typed before appending .org --
|
|
636
|
+
# otherwise a name like "mydoc.org" round-trips to "mydoc.org.org"
|
|
637
|
+
# (DocStore.normalize_name only strips ONE trailing extension, so
|
|
638
|
+
# blindly appending here is not idempotent against an already-typed one).
|
|
639
|
+
# Only strip when name is actually a String -- a non-String is
|
|
640
|
+
# passed through as-is so DocStore.save's own type check rejects
|
|
641
|
+
# it with the same ArgumentError the .rb path below already gets
|
|
642
|
+
# for free, instead of silently coercing it via #to_s.
|
|
643
|
+
org_name = name.is_a?(String) ? "#{name.sub(/\.(rb|org)\z/, '')}.org" : name
|
|
644
|
+
saved_path = StreamWeaver::Canvas::DocStore.save(org_name, org_text, scope: scope)
|
|
645
|
+
{ ok: true, path: saved_path, coverage: writer.coverage }.to_json
|
|
646
|
+
else
|
|
647
|
+
saved_path = StreamWeaver::Canvas::DocStore.save(name, dsl, scope: scope)
|
|
648
|
+
{ ok: true, path: saved_path }.to_json
|
|
649
|
+
end
|
|
650
|
+
end
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
# Deletes one saved doc (stream_weaver-uvaj). Body:
|
|
654
|
+
# {"path": "<absolute path>", "file": <currently-open index or null>}.
|
|
655
|
+
#
|
|
656
|
+
# Reader-only on purpose: BridgeServer has no doc inventory to delete
|
|
657
|
+
# from. Plain File.delete, no git shelling -- the unstaged deletion
|
|
658
|
+
# showing up in `git status` afterward is the intended outcome, and
|
|
659
|
+
# committing it is the user's call, same as deleting in Finder.
|
|
660
|
+
#
|
|
661
|
+
# 403 (not 404) for a path outside the two deletable roots: the
|
|
662
|
+
# request was understood and refused, and a 404 would leak whether the
|
|
663
|
+
# file exists. Never reachable from Browse mode, which can point at
|
|
664
|
+
# any directory on the machine -- arbitrary-path READ is a reviewed,
|
|
665
|
+
# accepted risk there; arbitrary-path DELETE would not be.
|
|
666
|
+
post '/delete-doc' do
|
|
667
|
+
content_type :json
|
|
668
|
+
body = begin
|
|
669
|
+
JSON.parse(request.body.read, symbolize_names: true)
|
|
670
|
+
rescue StandardError
|
|
671
|
+
{}
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
path = self.class.deletable_path(body[:path])
|
|
675
|
+
halt 403, { ok: false, error: 'Not a deletable doc path' }.to_json unless path
|
|
676
|
+
|
|
677
|
+
# Resolved BEFORE the delete, while the file is still in the list:
|
|
678
|
+
# this is the position the doc occupied, which is what tells the
|
|
679
|
+
# client whether the open doc's index shifts.
|
|
680
|
+
list = self.class.file_list
|
|
681
|
+
deleted = list&.files&.index { |f| self.class.canonical_file(f) == path }
|
|
682
|
+
|
|
683
|
+
begin
|
|
684
|
+
File.delete(path)
|
|
685
|
+
rescue SystemCallError => e
|
|
686
|
+
halt 500, { ok: false, error: e.message }.to_json
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
{ ok: true, path: path, file: self.class.index_after_delete(deleted, body[:file]) }.to_json
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
# Evaluates `dsl` and returns a Doc carrying the rendered HTML plus the
|
|
693
|
+
# theme/layout/inline CSS the DSL declared for itself -- the reader shell
|
|
694
|
+
# needs all four, and render_dsl's HTML-only return threw the rest away
|
|
695
|
+
# (stream_weaver-csf).
|
|
696
|
+
#
|
|
697
|
+
# Caveat: rendering goes through AppContentView, which does not evaluate
|
|
698
|
+
# exclusive-layout render blocks or layout slots (AppView-only concepts).
|
|
699
|
+
# So `layout` here only reaches body-class/CSS-selector level layout --
|
|
700
|
+
# a known, accepted gap, not something the reader can close.
|
|
701
|
+
#
|
|
702
|
+
# `mode: :websocket` is kept for component-markup parity with the canvas
|
|
703
|
+
# (some components render differently in websocket mode). The reader's
|
|
704
|
+
# <head> deliberately does NOT use adapter.cdn_scripts, which would drag
|
|
705
|
+
# in a connect attempt to a /canvas/reader/ws endpoint that doesn't exist.
|
|
706
|
+
#
|
|
707
|
+
# `inert: true` is the consequence of that omission: without cdn_scripts
|
|
708
|
+
# there is no `sendEvent`, so buttons and radio groups that would call it
|
|
709
|
+
# rendered as live controls which self-disabled and then threw a
|
|
710
|
+
# ReferenceError on click (disc-095). Inert renders them disabled with an
|
|
711
|
+
# explanatory title -- honest about being a preview -- rather than
|
|
712
|
+
# defining a no-op `sendEvent` stub, which would preserve the lie.
|
|
713
|
+
#
|
|
714
|
+
# @param path [String, nil] source file, passed through to instance_eval
|
|
715
|
+
# so a DSL error names the actual file/line instead of "(eval)".
|
|
716
|
+
#
|
|
717
|
+
# `dsl` may actually be `.org` text -- detected the same content-based
|
|
718
|
+
# way content.js/sandbox.js do client-side (StreamWeaver::Org::Reader.
|
|
719
|
+
# streamweaver_org?) and converted via Org::Reader.to_dsl before eval,
|
|
720
|
+
# same path the extension already uses. The converted text's line
|
|
721
|
+
# numbers no longer match the original .org file's, so a DSL error in
|
|
722
|
+
# a converted doc names the wrong line -- an accepted, precedented gap
|
|
723
|
+
# (the extension's sandbox.js has the same limitation; org->DSL is a
|
|
724
|
+
# real rewrite, not a 1:1 mapping).
|
|
725
|
+
#
|
|
726
|
+
# An `.org` file WITHOUT the marker (stream_weaver-gnj8 -- e.g. Browse
|
|
727
|
+
# pointed at $HOME turns up ordinary org-mode notes, not StreamWeaver
|
|
728
|
+
# docs) is caught before eval, not after: raw org markup is never
|
|
729
|
+
# valid Ruby, so letting it fall through to instance_eval only ever
|
|
730
|
+
# produces a confusing syntax error naming some fragment of prose. A
|
|
731
|
+
# `.rb` file with no StreamWeaver DSL calls doesn't get the same
|
|
732
|
+
# short-circuit -- it could legitimately be valid, unrelated Ruby
|
|
733
|
+
# (or invalid for an unrelated reason), so eval-and-show-DSL-error
|
|
734
|
+
# remains the right fallback there.
|
|
735
|
+
def self.render_doc(dsl, path: nil)
|
|
736
|
+
theme = fallback_theme
|
|
737
|
+
layout = fallback_layout
|
|
738
|
+
if path.to_s.end_with?('.org') && !StreamWeaver::Org::Reader.streamweaver_org?(dsl)
|
|
739
|
+
return Doc.new(html: not_a_streamweaver_doc_html, theme: theme, layout: layout, inline_stylesheets: [])
|
|
740
|
+
end
|
|
741
|
+
|
|
742
|
+
dsl = StreamWeaver::Org::Reader.to_dsl(dsl) if StreamWeaver::Org::Reader.streamweaver_org?(dsl)
|
|
743
|
+
mini_app = StreamWeaver::App.new('reader', theme: theme, layout: layout)
|
|
744
|
+
mini_app.instance_eval(dsl, path.to_s, 1)
|
|
745
|
+
adapter = StreamWeaver::Adapter::AlpineJS.new(
|
|
746
|
+
url_prefix: '/canvas/reader',
|
|
747
|
+
mode: :websocket,
|
|
748
|
+
inert: true
|
|
749
|
+
)
|
|
750
|
+
Doc.new(
|
|
751
|
+
html: StreamWeaver::Views::AppContentView.new(mini_app, {}, adapter, false).call,
|
|
752
|
+
theme: mini_app.theme,
|
|
753
|
+
layout: mini_app.layout,
|
|
754
|
+
inline_stylesheets: mini_app.inline_stylesheets
|
|
755
|
+
)
|
|
756
|
+
rescue ScriptError, StandardError => e
|
|
757
|
+
Doc.new(
|
|
758
|
+
html: "<div style='color:red;padding:1rem;font-family:monospace'>" \
|
|
759
|
+
"<strong>DSL error:</strong> #{CGI.escapeHTML(e.message)}</div>",
|
|
760
|
+
theme: theme,
|
|
761
|
+
layout: layout,
|
|
762
|
+
inline_stylesheets: []
|
|
763
|
+
)
|
|
764
|
+
end
|
|
765
|
+
|
|
766
|
+
def self.render_dsl(dsl)
|
|
767
|
+
render_doc(dsl).html
|
|
768
|
+
end
|
|
769
|
+
|
|
770
|
+
# Neutral, not red -- this isn't a bug in the doc or in canvas-read,
|
|
771
|
+
# it's just the wrong kind of file (stream_weaver-gnj8). Red/"error"
|
|
772
|
+
# styling here would read as "something's broken," which is the
|
|
773
|
+
# opposite of what a stray personal org file browsed into deserves.
|
|
774
|
+
def self.not_a_streamweaver_doc_html
|
|
775
|
+
"<div style='color:#4b5563;padding:1rem;font-family:monospace'>" \
|
|
776
|
+
"This doesn't look like a StreamWeaver doc -- no " \
|
|
777
|
+
"<code>#+STREAMWEAVER_DSL:</code> header found. It's probably a " \
|
|
778
|
+
"plain org-mode file.</div>"
|
|
779
|
+
end
|
|
780
|
+
private_class_method :not_a_streamweaver_doc_html
|
|
781
|
+
|
|
782
|
+
private
|
|
783
|
+
|
|
784
|
+
# Builds SaveDocWidget's `gist:` kwarg (share-to-gist epic,
|
|
785
|
+
# reader-gist-parity story) for the is_history Save-as-doc call site in
|
|
786
|
+
# reader_layout.erb. Kept out of the ERB itself -- reading the local
|
|
787
|
+
# GistStore file and shelling out to check `gh`'s presence are
|
|
788
|
+
# controller work, not view formatting (same rationale as
|
|
789
|
+
# #set_browse_ivars below).
|
|
790
|
+
#
|
|
791
|
+
# `name` is `default_name` (computed in the ERB from the archived
|
|
792
|
+
# snapshot's own filename) -- unlike BridgeServer#gist_widget_data,
|
|
793
|
+
# which does a `latest_for_prefix` search because a live canvas
|
|
794
|
+
# session's default name is a fresh timestamp on every dialog open, the
|
|
795
|
+
# reader's default_name is fixed per snapshot, so it's already the
|
|
796
|
+
# exact name a direct GistStore.lookup wants.
|
|
797
|
+
def gist_widget_data(name)
|
|
798
|
+
available = StreamWeaver::Canvas::GistPublisher.gh_available?
|
|
799
|
+
entry = StreamWeaver::Canvas::GistStore.lookup(name)
|
|
800
|
+
|
|
801
|
+
{
|
|
802
|
+
available: available,
|
|
803
|
+
unavailable_reason: available ? nil : 'gh CLI not found on PATH',
|
|
804
|
+
known: entry ? { name => { url: entry['url'], revisions: entry['revisions'] } } : {},
|
|
805
|
+
prefill_name: entry ? name : nil
|
|
806
|
+
}
|
|
807
|
+
end
|
|
808
|
+
|
|
809
|
+
# Every Browse-related link carries this instead of the chrome's
|
|
810
|
+
# narrower default (hx-select-oob="#sw-reader-nav" alone) -- Browse
|
|
811
|
+
# needs the sidebar's own CONTENT to change between directories,
|
|
812
|
+
# unlike normal docs/history navigation, which needs it to stay
|
|
813
|
+
# untouched so accordion state survives (stream_weaver-8v1). A
|
|
814
|
+
# boosted element's own hx-* attributes override its ancestor's, so
|
|
815
|
+
# this widening stays scoped to exactly the links that opt into it.
|
|
816
|
+
# One constant instead of nine hand-typed copies in the template:
|
|
817
|
+
# dropping #sw-reader-nav from any one of them would silently stop
|
|
818
|
+
# that link's swap from refreshing the nav bar.
|
|
819
|
+
BROWSE_OOB = 'hx-select-oob="#sw-reader-nav, #sw-reader-files"'
|
|
820
|
+
|
|
821
|
+
# The docs-mode counterpart of a bare GET /browse: sidebar and nav
|
|
822
|
+
# render against an empty list, and #app-container falls back to the
|
|
823
|
+
# same "pick a file" placeholder, because @doc is nil. Reached only
|
|
824
|
+
# when the last doc was deleted out from under the reader
|
|
825
|
+
# (stream_weaver-uvaj) -- @current_index stays unset, so the layout's
|
|
826
|
+
# Prev/Next/Export block is skipped exactly as it is for a browsed file.
|
|
827
|
+
def render_no_doc(list)
|
|
828
|
+
@file_list = list
|
|
829
|
+
@doc = nil
|
|
830
|
+
@mermaid_zoom_js = MERMAID_ZOOM_JS
|
|
831
|
+
erb :reader_layout, layout: false
|
|
832
|
+
end
|
|
833
|
+
|
|
834
|
+
# Populates every ivar reader_layout.erb's Browse sidebar needs,
|
|
835
|
+
# shared by GET /browse and GET /open so both compute breadcrumbs and
|
|
836
|
+
# the repo-root shortcut identically. Kept out of the ERB entirely --
|
|
837
|
+
# the breadcrumb walk and repo_docs_root's filesystem/.git lookup are
|
|
838
|
+
# controller work, not view formatting, and doing it here means it
|
|
839
|
+
# runs once per request instead of once per render.
|
|
840
|
+
def set_browse_ivars(dir)
|
|
841
|
+
@browse_mode = true
|
|
842
|
+
@browse_dir = dir
|
|
843
|
+
@browse_entries = self.class.browse_entries(dir)
|
|
844
|
+
@browse_parent = dir == '/' ? nil : File.dirname(dir)
|
|
845
|
+
|
|
846
|
+
parts = dir.split('/').reject(&:empty?)
|
|
847
|
+
@breadcrumbs = parts.each_index.map { |i| [parts[i], "/#{parts[0..i].join('/')}"] }
|
|
848
|
+
|
|
849
|
+
@repo_docs_root = self.class.repo_docs_root
|
|
850
|
+
end
|
|
851
|
+
end
|
|
852
|
+
end
|
|
853
|
+
end
|