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,478 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'phlex'
|
|
4
|
+
require 'base64'
|
|
5
|
+
require 'fileutils'
|
|
6
|
+
require 'net/http'
|
|
7
|
+
require_relative '../page_shell'
|
|
8
|
+
|
|
9
|
+
module StreamWeaver
|
|
10
|
+
module Export
|
|
11
|
+
# Raised when the DSL handed to the exporter isn't a canvas-doc fragment
|
|
12
|
+
# (see HtmlExporter.from_dsl).
|
|
13
|
+
class InvalidDslError < StandardError; end
|
|
14
|
+
|
|
15
|
+
# Raised when `offline:` can't fetch an asset it needs to inline (see
|
|
16
|
+
# HtmlExporter#mermaid_offline_script_tag).
|
|
17
|
+
class OfflineAssetError < StandardError; end
|
|
18
|
+
|
|
19
|
+
# Generates self-contained HTML files from StreamWeaver apps.
|
|
20
|
+
#
|
|
21
|
+
# Collects all CDN links (Mermaid, Chart.js, Prism.js, Google Fonts),
|
|
22
|
+
# inlines all CSS (theme + component styles), and renders the body
|
|
23
|
+
# via Phlex to produce a single .html file that works offline
|
|
24
|
+
# (except for CDN scripts which are preserved as external links).
|
|
25
|
+
#
|
|
26
|
+
# @example Export to file
|
|
27
|
+
# StreamWeaver::Export::HtmlExporter.export(app, path: "output.html")
|
|
28
|
+
#
|
|
29
|
+
# @example Get HTML string
|
|
30
|
+
# html = StreamWeaver::Export::HtmlExporter.new(app).to_html
|
|
31
|
+
#
|
|
32
|
+
# @example Export a canvas doc / history snapshot (a DSL fragment on disk)
|
|
33
|
+
# StreamWeaver::Export::HtmlExporter.from_dsl_file(path).export(path: out)
|
|
34
|
+
#
|
|
35
|
+
# The <head>/<body> shell comes from PageShell, so an export lands in the
|
|
36
|
+
# same cascade order as the canvas and the reader rather than re-deriving
|
|
37
|
+
# its own (stream_weaver-mdc).
|
|
38
|
+
class HtmlExporter
|
|
39
|
+
# Known CDN URLs for components that lazy-load scripts
|
|
40
|
+
CDN_MERMAID = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"
|
|
41
|
+
# The classic/global build (sets globalThis.mermaid), not the ESM one
|
|
42
|
+
# above -- used only by `offline:`, which inlines it as a plain
|
|
43
|
+
# <script> so it's covered by a CSP's 'unsafe-inline', not its
|
|
44
|
+
# (often absent) external-host allowlist. See #mermaid_offline_script_tag.
|
|
45
|
+
CDN_MERMAID_OFFLINE = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"
|
|
46
|
+
CDN_CHARTJS = "https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"
|
|
47
|
+
CDN_PRISMJS_CSS = "https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css"
|
|
48
|
+
CDN_PRISMJS_JS = "https://cdn.jsdelivr.net/npm/prismjs@1.29.0/prism.min.js"
|
|
49
|
+
CDN_PRISMJS_AUTOLOADER = "https://cdn.jsdelivr.net/npm/prismjs@1.29.0/plugins/autoloader/prism-autoloader.min.js"
|
|
50
|
+
CDN_ALPINE = "https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"
|
|
51
|
+
CDN_GOOGLE_FONTS = "https://fonts.googleapis.com/css2?family=Crimson+Pro:wght@400;500;600&family=Source+Sans+3:wght@400;500;600;700&display=swap"
|
|
52
|
+
|
|
53
|
+
# Ground truth for "does this export need Alpine.js" -- see
|
|
54
|
+
# #collect_cdn_scripts.
|
|
55
|
+
ALPINE_DIRECTIVE = /\sx-data=/
|
|
56
|
+
|
|
57
|
+
# A canvas-doc DSL fragment is a bare list of component calls -- it is
|
|
58
|
+
# instance_eval'd against an App the caller already made. A full
|
|
59
|
+
# standalone app file builds its own App and starts a server, so
|
|
60
|
+
# eval'ing one here either dies deep inside the DSL or boots a web
|
|
61
|
+
# server mid-export; a plain textual check catches it up front with a
|
|
62
|
+
# message that says what the input should have been.
|
|
63
|
+
#
|
|
64
|
+
# Deliberately literal, and it does not know about strings or comments:
|
|
65
|
+
# a doc whose code_block quotes `App.new` is a false positive. That
|
|
66
|
+
# trade is the right way round -- the export refuses with an
|
|
67
|
+
# explanation, versus silently hanging on a booted server.
|
|
68
|
+
FULL_APP_MARKERS = /App\.new|\.run!/
|
|
69
|
+
|
|
70
|
+
# @param app [StreamWeaver::App] The app to export
|
|
71
|
+
# @param state [Hash] State to render with (default: empty)
|
|
72
|
+
# @param base_dir [String, nil] Directory that relative asset paths in
|
|
73
|
+
# the doc resolve against (the source DSL file's dir). Defaults to
|
|
74
|
+
# the process working directory.
|
|
75
|
+
def initialize(app, state: {}, base_dir: nil)
|
|
76
|
+
@app = app
|
|
77
|
+
@state = state
|
|
78
|
+
@base_dir = base_dir
|
|
79
|
+
# An exported file has no server behind it at all, so a deck renders
|
|
80
|
+
# read-only rather than offering /deck/* calls nothing will answer.
|
|
81
|
+
@adapter = StreamWeaver::Adapter::AlpineJS.new(deck_server: false)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Build an exporter from a canvas-doc DSL fragment -- the shape a
|
|
85
|
+
# docs/streamweaver_canvas/*.rb file or a history snapshot has: bare
|
|
86
|
+
# component calls, no requires, no App.new/run! wrapper of its own.
|
|
87
|
+
#
|
|
88
|
+
# @param dsl [String] the fragment
|
|
89
|
+
# @param path [String, nil] source file, used for the <title>, for
|
|
90
|
+
# relative asset resolution, and so a DSL error names the real file
|
|
91
|
+
# @param theme [Symbol, nil] fallback for a doc with no use_theme
|
|
92
|
+
# @param layout [Symbol, nil] fallback for a doc with no use_layout
|
|
93
|
+
# @raise [InvalidDslError] when the fragment looks like a full app file
|
|
94
|
+
def self.from_dsl(dsl, path: nil, theme: nil, layout: nil)
|
|
95
|
+
if dsl.match?(FULL_APP_MARKERS)
|
|
96
|
+
raise InvalidDslError,
|
|
97
|
+
"expected a canvas-doc DSL fragment (bare component calls) but found " \
|
|
98
|
+
"App.new/run! -- export takes the same input as `streamweaver canvas-push`, " \
|
|
99
|
+
"not a standalone app file"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
title = path ? File.basename(path.to_s, '.rb') : 'StreamWeaver Export'
|
|
103
|
+
app = StreamWeaver::App.new(title, theme: theme || :default, layout: layout || :default)
|
|
104
|
+
app.instance_eval(dsl, path.to_s, 1)
|
|
105
|
+
new(app, base_dir: path && File.dirname(File.expand_path(path)))
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Reads `path` and builds an exporter from it. See .from_dsl.
|
|
109
|
+
def self.from_dsl_file(path, theme: nil, layout: nil)
|
|
110
|
+
from_dsl(File.read(path), path: path, theme: theme, layout: layout)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# A download-safe "<name>.html" derived from a source DSL path, allowing
|
|
114
|
+
# the same character set DocStore's doc-name allowlist does. DocStore
|
|
115
|
+
# raises on a bad name because a human typed it; here the name comes
|
|
116
|
+
# from a filesystem path the user didn't choose for this purpose, so we
|
|
117
|
+
# sanitize and fall back rather than fail the export.
|
|
118
|
+
def self.export_filename(source_path)
|
|
119
|
+
name = File.basename(source_path.to_s, '.rb')
|
|
120
|
+
.gsub(/[^A-Za-z0-9._-]+/, '-')
|
|
121
|
+
.gsub(/\.{2,}/, '.')
|
|
122
|
+
.sub(/\A[^A-Za-z0-9]+/, '')
|
|
123
|
+
"#{name.empty? ? 'export' : name}.html"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Export to a file
|
|
127
|
+
#
|
|
128
|
+
# @param path [String] Output file path
|
|
129
|
+
# @param inline_images [Boolean] Convert image src to base64 data URIs
|
|
130
|
+
# @param offline [Boolean] Fetch and inline mermaid's own library
|
|
131
|
+
# instead of referencing its CDN, so a diagram renders in a viewer
|
|
132
|
+
# whose CSP blocks every external host (SharePoint's HTML preview,
|
|
133
|
+
# etc.) -- see #mermaid_offline_script_tag. Requires network access
|
|
134
|
+
# at export time; no effect on docs with no mermaid component.
|
|
135
|
+
# @return [String] The output file path
|
|
136
|
+
def export(path:, inline_images: false, offline: false)
|
|
137
|
+
html = to_html(inline_images: inline_images, offline: offline)
|
|
138
|
+
dir = File.dirname(path)
|
|
139
|
+
FileUtils.mkdir_p(dir) unless File.directory?(dir)
|
|
140
|
+
File.write(path, html)
|
|
141
|
+
path
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Generate self-contained HTML string
|
|
145
|
+
#
|
|
146
|
+
# @param inline_images [Boolean] Convert local image src to base64 data URIs
|
|
147
|
+
# @param offline [Boolean] see #export
|
|
148
|
+
# @return [String] Complete HTML document
|
|
149
|
+
def to_html(inline_images: false, offline: false)
|
|
150
|
+
# Only block-built apps can be rebuilt. A canvas doc is instance_eval'd
|
|
151
|
+
# from a DSL *string* into a bare App, so @block is nil there and
|
|
152
|
+
# rebuilding would re-evaluate nothing, wiping every component
|
|
153
|
+
# (stream_weaver-65z).
|
|
154
|
+
# ALL_DEFERRED: a static export has no client to run a deferred
|
|
155
|
+
# fragment's auto-fetch, so every deferred block runs inline here or its
|
|
156
|
+
# content is silently lost to a placeholder.
|
|
157
|
+
@app.rebuild_with_state(@state, deferred_target: App::ALL_DEFERRED) if @app.block
|
|
158
|
+
|
|
159
|
+
body_html = render_body
|
|
160
|
+
body_html = inline_images_in_html(body_html) if inline_images
|
|
161
|
+
inline_mermaid = offline && components_include?(Components::Mermaid)
|
|
162
|
+
|
|
163
|
+
build_document(
|
|
164
|
+
title: @app.title,
|
|
165
|
+
css_html: css_html,
|
|
166
|
+
cdn_scripts: collect_cdn_scripts(body_html, skip_mermaid: inline_mermaid),
|
|
167
|
+
cdn_styles: collect_cdn_styles,
|
|
168
|
+
inline_scripts: inline_mermaid ? [mermaid_offline_script_tag] : [],
|
|
169
|
+
body_html: body_html
|
|
170
|
+
)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Class-level convenience method
|
|
174
|
+
#
|
|
175
|
+
# @param app [StreamWeaver::App] The app to export
|
|
176
|
+
# @param path [String] Output file path
|
|
177
|
+
# @param inline_images [Boolean] Convert local images to base64
|
|
178
|
+
# @param offline [Boolean] see #export
|
|
179
|
+
# @return [String] The output file path
|
|
180
|
+
def self.export(app, path:, inline_images: false, offline: false, state: {})
|
|
181
|
+
new(app, state: state).export(path: path, inline_images: inline_images, offline: offline)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
private
|
|
185
|
+
|
|
186
|
+
# Render body components to HTML string via Phlex
|
|
187
|
+
def render_body
|
|
188
|
+
StreamWeaver::ComponentRenderer.render_html(@adapter, @app.components, @state)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Fetches mermaid's classic global build (sets globalThis.mermaid --
|
|
192
|
+
# not the ESM build CDN_MERMAID points at) and inlines it as a plain
|
|
193
|
+
# <script>. A plain inline script is covered by a CSP's
|
|
194
|
+
# 'unsafe-inline', so it runs even in a viewer whose script-src
|
|
195
|
+
# doesn't allowlist any external host at all (stream_weaver-dnq) --
|
|
196
|
+
# the gap the non-offline fix (stream_weaver-4gs) couldn't close,
|
|
197
|
+
# since that one still referenced mermaid's CDN URL.
|
|
198
|
+
#
|
|
199
|
+
# sw-mermaid-zoom.js's loadMermaid() checks for this global before
|
|
200
|
+
# attempting its own dynamic import, so the rest of the mermaid
|
|
201
|
+
# pipeline (theme vars, zoom/pan, re-init on swap) is unchanged.
|
|
202
|
+
#
|
|
203
|
+
# Does not cover ELK layout (`elk: true` diagrams): ELK ships as a
|
|
204
|
+
# separate CDN module with no equivalent global build to fetch here,
|
|
205
|
+
# so an ELK diagram still needs network access to render even in an
|
|
206
|
+
# offline export. Rare enough (most diagrams use the default layout)
|
|
207
|
+
# that this is a documented limitation, not a blocker for v1.
|
|
208
|
+
#
|
|
209
|
+
# @raise [OfflineAssetError] if the fetch fails -- offline export
|
|
210
|
+
# still needs network access at *export* time, just not at *view*
|
|
211
|
+
# time; the message says so rather than surfacing a raw Net::HTTP
|
|
212
|
+
# or timeout error.
|
|
213
|
+
def mermaid_offline_script_tag
|
|
214
|
+
"<script>#{escape_inline_script(fetch_url(CDN_MERMAID_OFFLINE))}</script>"
|
|
215
|
+
rescue SocketError, SystemCallError, Timeout::Error, OpenSSL::SSL::SSLError, RuntimeError => e
|
|
216
|
+
raise OfflineAssetError,
|
|
217
|
+
"offline export couldn't fetch mermaid's library from #{CDN_MERMAID_OFFLINE} " \
|
|
218
|
+
"(#{e.message}) -- offline export still needs network access once, at export " \
|
|
219
|
+
"time, to embed the library; retry with a connection, or export without --offline"
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# `</script` and `<!--` are the two sequences that can terminate or
|
|
223
|
+
# mis-nest a <script> element's raw text -- the HTML tokenizer closes
|
|
224
|
+
# a script at the first `</script` it sees (case-insensitively, even
|
|
225
|
+
# inside a JS string/template literal/regex), and `<!--` inside a
|
|
226
|
+
# script's raw text starts "script data escaped state", after which
|
|
227
|
+
# the *next* `</script>` doesn't close the element either. Escaped
|
|
228
|
+
# this way both are inert to the tokenizer but unchanged to the JS
|
|
229
|
+
# engine: inside JS, `<\/script` and `<\!--` mean exactly `</script`
|
|
230
|
+
# and `<!--`, and neither is otherwise valid syntax on its own.
|
|
231
|
+
#
|
|
232
|
+
# Scoped to `</` followed specifically by "script" (a lookahead, not
|
|
233
|
+
# consumed) -- NOT every bare `</`. A first draft escaped every `</`
|
|
234
|
+
# and it corrupted real code: mermaid's own minified source contains
|
|
235
|
+
# `/</g` (a regex matching a literal "<"), and blindly inserting a
|
|
236
|
+
# backslash turned it into the invalid regex literal `/<\/g`, a JS
|
|
237
|
+
# syntax error that silently broke the entire inlined script --
|
|
238
|
+
# caught by a live browser check under a CSP that reproduces
|
|
239
|
+
# SharePoint's, not by any spec (none of them parse the output as JS).
|
|
240
|
+
#
|
|
241
|
+
# Correctness here can't rely on mermaid's own minifier already
|
|
242
|
+
# avoiding literal `</script`/`<!--` in its OUTPUT (it does, today) --
|
|
243
|
+
# CDN_MERMAID_OFFLINE is a floating minor-version URL re-fetched on
|
|
244
|
+
# every offline export, so this has to hold regardless of what
|
|
245
|
+
# jsDelivr serves tomorrow.
|
|
246
|
+
def escape_inline_script(js)
|
|
247
|
+
js.gsub(%r{</(?=script)}i, '<\/').gsub('<!--', '<\!--')
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# GET url, following redirects, with a timeout generous enough for
|
|
251
|
+
# mermaid's multi-MB bundle on a slow connection.
|
|
252
|
+
def fetch_url(url, redirects_left: 5)
|
|
253
|
+
raise "too many redirects fetching #{url}" if redirects_left <= 0
|
|
254
|
+
|
|
255
|
+
uri = URI(url)
|
|
256
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https',
|
|
257
|
+
open_timeout: 15, read_timeout: 60) do |http|
|
|
258
|
+
http.get(uri)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
case response
|
|
262
|
+
when Net::HTTPSuccess
|
|
263
|
+
# Net::HTTPResponse#body comes back ASCII-8BIT regardless of the
|
|
264
|
+
# response's actual charset -- force it to UTF-8 (what every
|
|
265
|
+
# asset this fetches in practice is) rather than let a later
|
|
266
|
+
# string interpolation against UTF-8 content (build_document's
|
|
267
|
+
# heredoc) raise Encoding::CompatibilityError deep in an
|
|
268
|
+
# unrelated method.
|
|
269
|
+
body = response.body.dup.force_encoding(Encoding::UTF_8)
|
|
270
|
+
raise "response body is not valid UTF-8" unless body.valid_encoding?
|
|
271
|
+
|
|
272
|
+
body
|
|
273
|
+
when Net::HTTPRedirection
|
|
274
|
+
# Location may be relative (RFC 7231) -- resolve against the URL
|
|
275
|
+
# that returned it rather than assuming an absolute URL.
|
|
276
|
+
redirect_uri = URI.join(uri, response['location'])
|
|
277
|
+
raise "redirect to non-https URL: #{redirect_uri}" unless redirect_uri.scheme == 'https'
|
|
278
|
+
|
|
279
|
+
fetch_url(redirect_uri.to_s, redirects_left: redirects_left - 1)
|
|
280
|
+
else
|
|
281
|
+
raise "HTTP #{response.code}"
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# The <head> style block, in the same cascade order the canvas and the
|
|
286
|
+
# reader use (PageShell is the single source of truth): framework layer,
|
|
287
|
+
# then the app's registered theme, then unlayered user CSS last so it
|
|
288
|
+
# outranks everything.
|
|
289
|
+
def css_html
|
|
290
|
+
parts = [StreamWeaver::PageShell.framework_css_html]
|
|
291
|
+
|
|
292
|
+
# Registered custom themes are NOT part of master_theme_css -- AppView
|
|
293
|
+
# emits them separately via render_custom_theme_css, and so must we.
|
|
294
|
+
if (theme = custom_theme)
|
|
295
|
+
parts << "<style>#{StreamWeaver::CSS.layer_wrap(theme.to_css)}</style>"
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
parts << StreamWeaver::PageShell.user_css_html(inline_stylesheets: @app.inline_stylesheets)
|
|
299
|
+
parts.join("\n")
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def custom_theme
|
|
303
|
+
return nil if @app.theme.nil? || StreamWeaver::App::BUILT_IN_THEMES.include?(@app.theme)
|
|
304
|
+
|
|
305
|
+
StreamWeaver.get_theme(@app.theme)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def body_class
|
|
309
|
+
"sw-theme-#{@app.theme} sw-layout-#{@app.layout}"
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Collect CDN script URLs based on what's actually in the rendered
|
|
313
|
+
# body.
|
|
314
|
+
#
|
|
315
|
+
# htmx/idiomorph are never included here: a static export has no
|
|
316
|
+
# server to talk to, so they're pure dead weight (and, on a
|
|
317
|
+
# CSP-locked-down viewer such as SharePoint's HTML preview, dead
|
|
318
|
+
# weight that also fails to load at all).
|
|
319
|
+
#
|
|
320
|
+
# Alpine.js is loaded only when the rendered markup actually contains
|
|
321
|
+
# an `x-data=` directive. This is checked against body_html itself
|
|
322
|
+
# rather than an allowlist of component classes: more than a dozen
|
|
323
|
+
# renderers in the adapter (tabs, charts, slide containers, dropdowns,
|
|
324
|
+
# copy buttons, sortable tables, ...) emit Alpine directives, and an
|
|
325
|
+
# allowlist here would silently drift every time one of them changes
|
|
326
|
+
# -- exactly the bug this method used to have, just for Alpine instead
|
|
327
|
+
# of mermaid (stream_weaver-4gs). Mermaid needs no entry in this
|
|
328
|
+
# check: sw-mermaid-zoom.js self-inits without any Alpine directive.
|
|
329
|
+
#
|
|
330
|
+
# @param skip_mermaid [Boolean] true when the caller is inlining
|
|
331
|
+
# mermaid's library itself (see #mermaid_offline_script_tag) -- the
|
|
332
|
+
# external CDN reference below would be redundant and, on the CSP
|
|
333
|
+
# this flag exists to work around, would just be an extra blocked
|
|
334
|
+
# request.
|
|
335
|
+
def collect_cdn_scripts(body_html, skip_mermaid: false)
|
|
336
|
+
scripts = []
|
|
337
|
+
|
|
338
|
+
scripts << { src: CDN_ALPINE, defer: true } if body_html.match?(ALPINE_DIRECTIVE)
|
|
339
|
+
|
|
340
|
+
# Mermaid - check if any mermaid components exist
|
|
341
|
+
if components_include?(Components::Mermaid) && !skip_mermaid
|
|
342
|
+
scripts << { src: CDN_MERMAID, type: "module" }
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# Chart.js -- keyed on the whole chart family, not just the one class
|
|
346
|
+
# the generic `chart type:` DSL builds. Every shorthand (bar_chart,
|
|
347
|
+
# pie_chart, sparkline, ...) builds a Components::ChartBase subclass,
|
|
348
|
+
# none of which is a Components::Chart, so gating on Chart alone
|
|
349
|
+
# shipped exports with no library at all; the adapter's
|
|
350
|
+
# `if (typeof Chart !== 'undefined')` x-init guard then swallowed it
|
|
351
|
+
# into an empty box with a silent console (disc-094). Naming the base
|
|
352
|
+
# class means a new chart type is covered the moment it subclasses.
|
|
353
|
+
if components_include?(Components::Chart) || components_include?(Components::ChartBase)
|
|
354
|
+
scripts << { src: CDN_CHARTJS }
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Prism.js for code blocks
|
|
358
|
+
if components_include?(Components::CodeBlock)
|
|
359
|
+
scripts << { src: CDN_PRISMJS_JS }
|
|
360
|
+
scripts << { src: CDN_PRISMJS_AUTOLOADER }
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Custom scripts from the app
|
|
364
|
+
@app.scripts.each do |src|
|
|
365
|
+
scripts << { src: src }
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
scripts
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Collect CDN stylesheet URLs
|
|
372
|
+
def collect_cdn_styles
|
|
373
|
+
styles = []
|
|
374
|
+
|
|
375
|
+
# Google Fonts
|
|
376
|
+
styles << CDN_GOOGLE_FONTS
|
|
377
|
+
|
|
378
|
+
# Prism.js theme CSS
|
|
379
|
+
if components_include?(Components::CodeBlock)
|
|
380
|
+
styles << CDN_PRISMJS_CSS
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Custom stylesheets from the app
|
|
384
|
+
@app.stylesheets.each do |href|
|
|
385
|
+
styles << href
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
styles
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# Check if any component (including nested) is an instance of a class
|
|
392
|
+
def components_include?(klass)
|
|
393
|
+
check_components(@app.components, klass)
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def check_components(components, klass)
|
|
397
|
+
components.any? do |c|
|
|
398
|
+
c.is_a?(klass) ||
|
|
399
|
+
(c.respond_to?(:children) && c.children && check_components(c.children, klass))
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# Replace local image paths with base64 data URIs in HTML.
|
|
404
|
+
# Relative paths resolve against the source DSL file's directory, not
|
|
405
|
+
# Dir.pwd -- a doc referencing ./diagram.png means the one next to it,
|
|
406
|
+
# wherever the export happens to be run from.
|
|
407
|
+
def inline_images_in_html(html)
|
|
408
|
+
html.gsub(/src="((?!data:|https?:\/\/)[^"]+)"/) do |match|
|
|
409
|
+
path = File.expand_path($1, @base_dir || Dir.pwd)
|
|
410
|
+
if File.exist?(path)
|
|
411
|
+
mime = case File.extname(path).downcase
|
|
412
|
+
when '.png' then 'image/png'
|
|
413
|
+
when '.jpg', '.jpeg' then 'image/jpeg'
|
|
414
|
+
when '.gif' then 'image/gif'
|
|
415
|
+
when '.svg' then 'image/svg+xml'
|
|
416
|
+
when '.webp' then 'image/webp'
|
|
417
|
+
else 'application/octet-stream'
|
|
418
|
+
end
|
|
419
|
+
data = Base64.strict_encode64(File.binread(path))
|
|
420
|
+
"src=\"data:#{mime};base64,#{data}\""
|
|
421
|
+
else
|
|
422
|
+
match
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# Build the complete HTML document
|
|
428
|
+
#
|
|
429
|
+
# @param inline_scripts [Array<String>] raw "<script>...</script>"
|
|
430
|
+
# blocks (offline-fetched assets) emitted after cdn_scripts, so an
|
|
431
|
+
# inlined replacement -- e.g. mermaid's library -- is present before
|
|
432
|
+
# any script later in the document expects it.
|
|
433
|
+
def build_document(title:, css_html:, cdn_scripts:, cdn_styles:, body_html:, inline_scripts: [])
|
|
434
|
+
tags = cdn_scripts.map { |s| cdn_script_tag(s) } + inline_scripts
|
|
435
|
+
script_tags = tags.join("\n ")
|
|
436
|
+
|
|
437
|
+
style_tags = cdn_styles.map do |href|
|
|
438
|
+
"<link rel=\"stylesheet\" href=\"#{href}\">"
|
|
439
|
+
end.join("\n ")
|
|
440
|
+
|
|
441
|
+
<<~HTML
|
|
442
|
+
<!DOCTYPE html>
|
|
443
|
+
<html lang="en">
|
|
444
|
+
<head>
|
|
445
|
+
<meta charset="utf-8">
|
|
446
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
447
|
+
<title>#{escape_html(title)}</title>
|
|
448
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
449
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
450
|
+
#{style_tags}
|
|
451
|
+
#{script_tags}
|
|
452
|
+
#{css_html}
|
|
453
|
+
</head>
|
|
454
|
+
<body class="#{body_class}">
|
|
455
|
+
<!-- #app-container is a DIRECT body child, as the canvas and reader
|
|
456
|
+
render it: theme/sidebar_toc selectors use the ">" combinator
|
|
457
|
+
against body[class*="sw-layout-"] > #app-container. -->
|
|
458
|
+
<div id="app-container">
|
|
459
|
+
#{body_html}
|
|
460
|
+
</div>
|
|
461
|
+
</body>
|
|
462
|
+
</html>
|
|
463
|
+
HTML
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
def cdn_script_tag(script)
|
|
467
|
+
attrs = ["src=\"#{escape_html(script[:src])}\""]
|
|
468
|
+
attrs << "defer" if script[:defer]
|
|
469
|
+
attrs << "type=\"#{script[:type]}\"" if script[:type]
|
|
470
|
+
"<script #{attrs.join(' ')}></script>"
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def escape_html(text)
|
|
474
|
+
text.to_s.gsub("&", "&").gsub("<", "<").gsub(">", ">").gsub('"', """)
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
end
|
|
478
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'uri'
|
|
5
|
+
|
|
6
|
+
module StreamWeaver
|
|
7
|
+
# HTTP push client for sending targeted DOM updates to a running StreamWeaver app.
|
|
8
|
+
# Accepts either raw HTML strings or component DSL blocks.
|
|
9
|
+
#
|
|
10
|
+
# @example Raw HTML
|
|
11
|
+
# feed = StreamWeaver.connect("Live Monitor")
|
|
12
|
+
# feed.replace("#clock", "<span>#{Time.now}</span>")
|
|
13
|
+
#
|
|
14
|
+
# @example Component DSL
|
|
15
|
+
# feed.replace("#metric-rps") do
|
|
16
|
+
# card { stat_display value: 3500, label: "REQ/SEC", color: :blue }
|
|
17
|
+
# end
|
|
18
|
+
class Feed
|
|
19
|
+
include Pushable
|
|
20
|
+
|
|
21
|
+
attr_reader :url
|
|
22
|
+
|
|
23
|
+
def initialize(url)
|
|
24
|
+
@url = url
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def push_update(action:, target:, html:)
|
|
30
|
+
uri = URI("#{@url}/stream/push")
|
|
31
|
+
Net::HTTP.post_form(uri, target: target, action: action.to_s, html: html)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StreamWeaver
|
|
4
|
+
# Display-only component DSL for feed context.
|
|
5
|
+
# Uses the same DisplayDSL as App but omits interactive components.
|
|
6
|
+
#
|
|
7
|
+
# @example
|
|
8
|
+
# components = FeedBuilder.build do
|
|
9
|
+
# card { stat_display value: 42, label: "RPS", color: :blue }
|
|
10
|
+
# end
|
|
11
|
+
class FeedBuilder
|
|
12
|
+
include DisplayDSL
|
|
13
|
+
|
|
14
|
+
attr_accessor :components
|
|
15
|
+
|
|
16
|
+
def initialize(state = nil)
|
|
17
|
+
@components = []
|
|
18
|
+
@_state = state
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def state
|
|
22
|
+
@_state
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.build(state = nil, &block)
|
|
26
|
+
builder = new(state)
|
|
27
|
+
builder.instance_eval(&block)
|
|
28
|
+
builder.components
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StreamWeaver
|
|
4
|
+
# Builds Google Fonts link tags from a declarative fonts list.
|
|
5
|
+
#
|
|
6
|
+
# Accepts an array of:
|
|
7
|
+
# "Family:wght@400;700" — string shorthand (treated as Google font)
|
|
8
|
+
# { google: "Family:wght@400;700" } — explicit Google font
|
|
9
|
+
# { src: "/fonts/foo.woff2", family: "Foo" } — self-hosted (future, not yet emitted)
|
|
10
|
+
module Fonts
|
|
11
|
+
# Build a combined Google Fonts href from the entries that declare Google fonts.
|
|
12
|
+
#
|
|
13
|
+
# @param fonts [Array<String, Hash>]
|
|
14
|
+
# @return [String, nil] Fully-qualified googleapis CSS2 URL, or nil if no Google fonts
|
|
15
|
+
def self.google_fonts_href(fonts)
|
|
16
|
+
google_families = fonts.filter_map do |entry|
|
|
17
|
+
case entry
|
|
18
|
+
when String then entry
|
|
19
|
+
when Hash then entry[:google]
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
return nil if google_families.empty?
|
|
23
|
+
|
|
24
|
+
families = google_families.map { |f| "family=#{f}" }.join("&")
|
|
25
|
+
"https://fonts.googleapis.com/css2?#{families}&display=swap"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Returns true if any entry is a Google font.
|
|
29
|
+
def self.google_fonts?(fonts)
|
|
30
|
+
fonts.any? { |e| e.is_a?(String) || (e.is_a?(Hash) && e.key?(:google)) }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|