streamtex 0.7.74__tar.gz → 0.7.76__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. {streamtex-0.7.74/streamtex.egg-info → streamtex-0.7.76}/PKG-INFO +1 -1
  2. {streamtex-0.7.74 → streamtex-0.7.76}/pyproject.toml +1 -1
  3. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/book.py +13 -1
  4. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/release_cmd.py +76 -0
  5. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/deploy/stx_auth.py +1 -1
  6. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/presentation.py +19 -1
  7. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/release/config.py +23 -4
  8. streamtex-0.7.76/streamtex/release/media.py +153 -0
  9. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/release/ops.py +73 -7
  10. {streamtex-0.7.74 → streamtex-0.7.76/streamtex.egg-info}/PKG-INFO +1 -1
  11. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex.egg-info/SOURCES.txt +1 -0
  12. {streamtex-0.7.74 → streamtex-0.7.76}/LICENSE +0 -0
  13. {streamtex-0.7.74 → streamtex-0.7.76}/MANIFEST.in +0 -0
  14. {streamtex-0.7.74 → streamtex-0.7.76}/README.md +0 -0
  15. {streamtex-0.7.74 → streamtex-0.7.76}/setup.cfg +0 -0
  16. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/__init__.py +0 -0
  17. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/__main__.py +0 -0
  18. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/ai/__init__.py +0 -0
  19. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/ai/config.py +0 -0
  20. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/ai/generate.py +0 -0
  21. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/ai/history.py +0 -0
  22. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/ai/metadata.py +0 -0
  23. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/ai/providers/__init__.py +0 -0
  24. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/ai/providers/base.py +0 -0
  25. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/ai/providers/fal.py +0 -0
  26. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/ai/providers/google.py +0 -0
  27. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/ai/providers/openai.py +0 -0
  28. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/ai/providers/registry.py +0 -0
  29. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/auth.py +0 -0
  30. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/banner.py +0 -0
  31. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/bib.py +0 -0
  32. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/bib_preview.py +0 -0
  33. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/block_helpers.py +0 -0
  34. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/blocks.py +0 -0
  35. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/browser.py +0 -0
  36. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/__init__.py +0 -0
  37. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/_claude_files.py +0 -0
  38. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/_divergence.py +0 -0
  39. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/_install_local_packs.py +0 -0
  40. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/_shared.py +0 -0
  41. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/_stx_toml.py +0 -0
  42. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/_toml_helpers.py +0 -0
  43. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/artifact_cmd.py +0 -0
  44. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/bib_cmd.py +0 -0
  45. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/build_check.py +0 -0
  46. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/cache_cmd.py +0 -0
  47. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/claude_cmd.py +0 -0
  48. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/claude_global.py +0 -0
  49. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/claude_project.py +0 -0
  50. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/commands.py +0 -0
  51. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/component_cmd.py +0 -0
  52. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/console.py +0 -0
  53. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/coolify.py +0 -0
  54. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/deploy_auth_cmd.py +0 -0
  55. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/deploy_cmd.py +0 -0
  56. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/dev_cmd.py +0 -0
  57. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/dev_config.py +0 -0
  58. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/ds_cmd.py +0 -0
  59. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/export_cmd.py +0 -0
  60. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/install_cmd.py +0 -0
  61. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/kit_cmd.py +0 -0
  62. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/main.py +0 -0
  63. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/migrations/__init__.py +0 -0
  64. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/migrations/base.py +0 -0
  65. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/migrations/compat.py +0 -0
  66. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/migrations/registry.py +0 -0
  67. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/migrations/v0_4_0.py +0 -0
  68. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/migrations/v0_4_6.py +0 -0
  69. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/pack_cmd.py +0 -0
  70. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/project_cmd.py +0 -0
  71. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/project_rules.py +0 -0
  72. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/publish_cmd.py +0 -0
  73. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/published_check.py +0 -0
  74. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/run_cmd.py +0 -0
  75. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/run_set.py +0 -0
  76. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/screenshot_cmd.py +0 -0
  77. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/shortcuts.py +0 -0
  78. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/status_cmd.py +0 -0
  79. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/sync_cmd.py +0 -0
  80. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/upgrade_cmd.py +0 -0
  81. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/validate_cmd.py +0 -0
  82. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/workspace_cmd.py +0 -0
  83. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/code.py +0 -0
  84. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/collection.py +0 -0
  85. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/constants.py +0 -0
  86. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/container.py +0 -0
  87. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/core/__init__.py +0 -0
  88. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/core/artifacts/__init__.py +0 -0
  89. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/core/artifacts/_frontmatter.py +0 -0
  90. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/core/artifacts/agent.py +0 -0
  91. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/core/artifacts/ai_prompt.py +0 -0
  92. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/core/artifacts/archetype.py +0 -0
  93. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/core/artifacts/asset.py +0 -0
  94. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/core/artifacts/discovery.py +0 -0
  95. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/core/artifacts/guideline.py +0 -0
  96. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/core/artifacts/install.py +0 -0
  97. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/core/artifacts/integration.py +0 -0
  98. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/core/artifacts/loaders.py +0 -0
  99. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/core/artifacts/palette.py +0 -0
  100. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/core/artifacts/skill.py +0 -0
  101. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/core/artifacts/spec.py +0 -0
  102. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/core/artifacts/validation.py +0 -0
  103. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/core/contracts.py +0 -0
  104. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/core/discovery.py +0 -0
  105. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/core/validation.py +0 -0
  106. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/countdown.py +0 -0
  107. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/deeplink.py +0 -0
  108. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/deploy/__init__.py +0 -0
  109. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/enums.py +0 -0
  110. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/export.py +0 -0
  111. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/export_enrich.py +0 -0
  112. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/export_paginate.py +0 -0
  113. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/export_widgets.py +0 -0
  114. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/facts.py +0 -0
  115. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/grid.py +0 -0
  116. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/gsheet.py +0 -0
  117. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/hover_tooltip.py +0 -0
  118. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/i18n.py +0 -0
  119. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/image.py +0 -0
  120. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/image_crop.py +0 -0
  121. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/image_editor.py +0 -0
  122. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/image_utils.py +0 -0
  123. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/inspector.py +0 -0
  124. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/kept.py +0 -0
  125. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/latex.py +0 -0
  126. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/latex_utils.py +0 -0
  127. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/link_config.py +0 -0
  128. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/link_preview.py +0 -0
  129. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/list.py +0 -0
  130. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/loading.py +0 -0
  131. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/markdown.py +0 -0
  132. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/marker.py +0 -0
  133. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/marker_runtime.py +0 -0
  134. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/media_overlay.py +0 -0
  135. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/mermaid.py +0 -0
  136. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/overlay.py +0 -0
  137. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/pagewalk.py +0 -0
  138. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/pdf_export.py +0 -0
  139. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/plantuml.py +0 -0
  140. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/presentation_profile.py +0 -0
  141. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/printing.py +0 -0
  142. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/release/__init__.py +0 -0
  143. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/release/build.py +0 -0
  144. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/release/coolify.py +0 -0
  145. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/release/levels.py +0 -0
  146. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/release/passwords.py +0 -0
  147. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/release/state.py +0 -0
  148. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/release/target.py +0 -0
  149. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/release/templates/live/Dockerfile +0 -0
  150. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/release/templates/live/entrypoint.sh +0 -0
  151. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/release/templates/live/nginx.conf +0 -0
  152. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/release/templates/live/stx-http.conf +0 -0
  153. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/release/templates/live/supervisord.conf +0 -0
  154. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/release/templates/static/10-stx-init.sh +0 -0
  155. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/release/templates/static/20-stx-auth.sh +0 -0
  156. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/release/templates/static/Dockerfile +0 -0
  157. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/release/templates/static/closed.html +0 -0
  158. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/release/templates/static/nginx.conf +0 -0
  159. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/release/templates/static/stx-http.conf +0 -0
  160. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/release/templates/static/stx_auth.py +0 -0
  161. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/runtime_flags.py +0 -0
  162. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/scripts/__init__.py +0 -0
  163. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/scripts/migrate_curves_to_relative.py +0 -0
  164. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/search.py +0 -0
  165. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/slide.py +0 -0
  166. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/slide_note.py +0 -0
  167. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/space.py +0 -0
  168. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/spacing.py +0 -0
  169. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/static/css/stx_global.css +0 -0
  170. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/static/default.css +0 -0
  171. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/static/js/stx_countdown.js +0 -0
  172. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/static/js/stx_marker_observer.js +0 -0
  173. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/static/js/stx_scroll_spy.js +0 -0
  174. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/static/js/stx_video.js +0 -0
  175. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/static/logo-stx-tiny.png +0 -0
  176. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/static/logo-stx.png +0 -0
  177. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/styles/__init__.py +0 -0
  178. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/styles/base.py +0 -0
  179. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/styles/container.py +0 -0
  180. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/styles/core.py +0 -0
  181. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/styles/scale.py +0 -0
  182. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/styles/scale_curves.toml +0 -0
  183. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/styles/text.py +0 -0
  184. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/styles/visibility.py +0 -0
  185. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/tikz.py +0 -0
  186. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/toc.py +0 -0
  187. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/utils.py +0 -0
  188. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/watch.py +0 -0
  189. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/write.py +0 -0
  190. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/zoom.py +0 -0
  191. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex.egg-info/dependency_links.txt +0 -0
  192. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex.egg-info/entry_points.txt +0 -0
  193. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex.egg-info/requires.txt +0 -0
  194. {streamtex-0.7.74 → streamtex-0.7.76}/streamtex.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: streamtex
3
- Version: 0.7.74
3
+ Version: 0.7.76
4
4
  Summary: AI-powered content framework for Streamlit — create presentations, courses, and web-books with Claude or Cursor, no coding required.
5
5
  Author-email: Nicolas Guelfi <nicolas.guelfi@laposte.net>
6
6
  License-Expression: BUSL-1.1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "streamtex"
7
- version = "0.7.74"
7
+ version = "0.7.76"
8
8
  description = "AI-powered content framework for Streamlit — create presentations, courses, and web-books with Claude or Cursor, no coding required."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -58,7 +58,7 @@ from .marker import (
58
58
  )
59
59
  from .marker_runtime import inject_marker_runtime
60
60
  from .pdf_export import PdfConfig
61
- from .presentation import get_presentation_config, st_presentation_footer
61
+ from .presentation import get_presentation_config, presentation_page_scrolls, st_presentation_footer
62
62
  from .presentation_profile import (
63
63
  _ACTIVE_PROFILE_KEY,
64
64
  PresentationProfile,
@@ -482,6 +482,10 @@ def _offer_export_downloads(html: str, base_name: str,
482
482
 
483
483
  submitted = st.form_submit_button("Generate")
484
484
 
485
+ # A Generate that had to rebuild the full document first is carried over its rerun (#151):
486
+ # a form counts as submitted on one run only, so the reader had to click a second time.
487
+ _pending_key = f"_stx_export_pending_{_pk}"
488
+ submitted = submitted or st.session_state.pop(_pending_key, False)
485
489
  if submitted:
486
490
  results = {}
487
491
  # Resolve which HTML to use based on scope
@@ -494,6 +498,7 @@ def _offer_export_downloads(html: str, base_name: str,
494
498
  if rebuild_fn:
495
499
  with st.spinner("Generating full document..."):
496
500
  rebuild_fn()
501
+ st.session_state[_pending_key] = True
497
502
  st.rerun()
498
503
  if full_book_html:
499
504
  # Built once per session with the view of that moment:
@@ -2308,6 +2313,11 @@ def _render_page(module, page: dict, pages: list[dict], block_args, block_kwargs
2308
2313
  st_include(module, *block_args, _inspector_config=inspector, **block_kwargs)
2309
2314
 
2310
2315
 
2316
+ def _stops_on_page(markers: list[dict], page: int) -> int:
2317
+ """How many slides (navigation stops) *page* holds."""
2318
+ return sum(1 for m in markers if is_stop(m) and m.get("page_idx", 0) == page)
2319
+
2320
+
2311
2321
  def _footer_first_slide(markers: list[dict], page: int, initial_marker: int | None) -> int:
2312
2322
  """The slide number the footer shows on its first paint (#117).
2313
2323
 
@@ -3652,6 +3662,8 @@ def _paginated_book(module_list, toc_config, marker_config, separator,
3652
3662
 
3653
3663
  # --- Presentation footer (auto-rendered when PresentationConfig is active) ---
3654
3664
  _pres_cfg = get_presentation_config()
3665
+ if _pres_cfg is not None and _stops_on_page(cache.get("markers", []), current_page) > 1:
3666
+ presentation_page_scrolls(_pres_cfg) # several slides on this page (#153)
3655
3667
  if _pres_cfg is not None and _pres_cfg.footer:
3656
3668
  _n = stop_count() if _pres_cfg.counter_mode == "slide" else 0
3657
3669
  if _n:
@@ -259,6 +259,82 @@ def wake_cmd(site, target):
259
259
  click.echo(f"{site}: live face running again")
260
260
 
261
261
 
262
+ @release.command("remove")
263
+ @click.argument("site")
264
+ @_target
265
+ @click.option("--yes", is_flag=True, help="Do not ask for confirmation.")
266
+ @click.option("--force", is_flag=True, help="Remove a site still declared in stx.toml.")
267
+ def remove_cmd(site, target, yes, force):
268
+ """Take SITE off the server (#143): its program, its port, its folders, its state entry.
269
+
270
+ Refused while SITE is still declared in stx.toml, unless --force. No other site is touched and the
271
+ project's media stay. The domains are not changed here: `stx release setup --domains --dry-run`
272
+ shows them, then `--domains --deploy` writes them (it redeploys both applications)."""
273
+ cfg, state, t = _ctx(target)
274
+ if site in cfg.by_name and not force:
275
+ raise ReleaseError(f"{site} is still declared in stx.toml — remove it there first, or pass --force")
276
+ plan = ops.remove_plan(t, state, site)
277
+ if not plan:
278
+ raise ReleaseError(f"{site}: nothing on {t.name}")
279
+ click.echo(f"On {t.name}, this removes:")
280
+ for line in plan:
281
+ click.echo(f" - {line}")
282
+ if not yes and not click.confirm("Remove?", default=False):
283
+ raise ReleaseError("nothing removed")
284
+ ops.remove_site(t, cfg, state, site, click.echo)
285
+ if site not in cfg.by_name:
286
+ click.echo("Domains: `stx release setup --domains --dry-run` shows the one to drop; "
287
+ "`--domains --deploy` writes them (redeploys both applications, out of class).")
288
+
289
+
290
+ @release.command("media-report")
291
+ @click.argument("site")
292
+ @click.option("--list", "show", type=click.Choice(["kept", "dropped"]), default=None,
293
+ help="Also list the files kept, or those no longer served.")
294
+ @click.option("--html-env", "html_env", multiple=True, help="KEY=VALUE for the HTML export, as for publish.")
295
+ def media_report_cmd(site, show, html_env):
296
+ """What SITE's live face would serve with `[release] media_per_site = true` (#142), on this machine.
297
+
298
+ Exports SITE's HTML from the commit, as publish does, then sorts its static folder: the files the
299
+ export shows and those media_keep names are kept, the others would no longer be served. Nothing is
300
+ sent: the server is not read nor changed."""
301
+ import tempfile
302
+ from pathlib import Path
303
+
304
+ from ..release import media as md
305
+
306
+ cfg = load_config()
307
+ s = cfg.site(site)
308
+ if "html" not in s.faces:
309
+ raise ReleaseError(f"{site} has no HTML face: its live face serves its whole static folder")
310
+ with tempfile.TemporaryDirectory(prefix="stx-media-") as w:
311
+ work = Path(w)
312
+ code = build.snapshot(cfg, s, work)
313
+ html, _ = build.export(cfg, s, code, work, "media-report", _html_env(html_env))
314
+ mp = md.plan(cfg, s, code, html)
315
+ if mp.static is None:
316
+ raise ReleaseError(f"{site}: {s.doc_dir}/static is not a folder — the live face serves no media")
317
+ kept, dropped = sorted(mp.kept), mp.dropped
318
+ by_export = sum(1 for why in mp.kept.values() if why == "export")
319
+ click.echo(f"{site} — static folder {mp.static} ({len(mp.files)} files, {md.human(mp.size(mp.files))})")
320
+ click.echo(f" kept: {len(kept)} file(s), {md.human(mp.size(kept))} — {by_export} shown by the HTML "
321
+ f"export, {len(kept) - by_export} named by media_keep")
322
+ click.echo(f" no longer served: {len(dropped)} file(s), {md.human(mp.size(dropped))}")
323
+ if mp.unmatched:
324
+ click.echo(f" {mp.unmatched} export file(s) match no static file (inlined or generated: nothing to keep)")
325
+ if mp.named:
326
+ click.echo(f" ! named in the site's files but not kept — add them to media_keep if the live face "
327
+ f"needs them ({len(mp.named)}):")
328
+ for p in mp.named:
329
+ click.echo(f" {p}")
330
+ if show:
331
+ click.echo(f"{'Kept' if show == 'kept' else 'No longer served'}:")
332
+ for p in (kept if show == "kept" else dropped):
333
+ click.echo(f" {p}" + (f" ({mp.kept[p]})" if show == "kept" else ""))
334
+ click.echo(f"[release] media_per_site = {'true: applied' if cfg.media_per_site else 'false: nothing changes'}"
335
+ " at the next publication")
336
+
337
+
262
338
  @release.command("lock")
263
339
  def lock_cmd():
264
340
  """Write the live image's requirements.txt and stx-version from uv.lock (then rebuild the live app).
@@ -107,7 +107,7 @@ from pathlib import Path
107
107
  from urllib.parse import parse_qs, urlsplit
108
108
 
109
109
  #: The library release this file ships with, and its interface number (#135).
110
- STX_VERSION = "0.7.74"
110
+ STX_VERSION = "0.7.76"
111
111
  AUTH_INTERFACE = 3
112
112
 
113
113
  DEFAULT_ROOT = "/var/lib/stx-auth"
@@ -47,7 +47,9 @@ class PresentationConfig:
47
47
  """CSS aspect-ratio value ("16/9", "4/3", "16/10")."""
48
48
 
49
49
  enforce_ratio: bool = True
50
- """When True, apply aspect-ratio + overflow:hidden to the slide container."""
50
+ """When True, the page is clipped at the screen's height (overflow hidden).
51
+ A page holding several slides is not clipped: it scrolls, so the
52
+ navigation reaches each of its slides (#153)."""
51
53
 
52
54
  # Footer
53
55
  footer: bool = True
@@ -287,6 +289,22 @@ def _inject_presentation_css(config: PresentationConfig) -> None:
287
289
  st.html(css)
288
290
 
289
291
 
292
+ def presentation_page_scrolls(config: PresentationConfig) -> None:
293
+ """Let the current page scroll when it holds several slides (#153).
294
+
295
+ With ``enforce_ratio`` the page is clipped at the screen's height and,
296
+ with ``center_content``, centred in it: a page of two slides (two
297
+ screens high) had its first slide above the screen and the second below
298
+ it, out of the navigation's reach. Such a page now grows and scrolls as
299
+ without presentation mode, so the navigation reaches each slide. A page
300
+ of one slide keeps the clipping.
301
+ """
302
+ if not config.enforce_ratio:
303
+ return
304
+ st.html("<style>.stMain .block-container { max-height: none !important; overflow: visible !important; "
305
+ "justify-content: flex-start !important; flex: 0 0 auto !important; }</style>")
306
+
307
+
290
308
  # ---------------------------------------------------------------------------
291
309
  # Footer rendering
292
310
  # ---------------------------------------------------------------------------
@@ -9,6 +9,9 @@
9
9
  snapshot = ["content", "shared", "trainings_pack"] # in every code snapshot, besides the site's paths
10
10
  media = "assets" # copied once per project, linked into every snapshot (optional)
11
11
  media_exclude = [".DS_Store", "*-original.*"]
12
+ media_per_site = true # the live face serves only the site's own media (#142; default false):
13
+ # those its HTML export shows, plus media_keep — see release/media.py
14
+ media_keep = ["media/videos/*"] # globs under the site's static folder, served by every site
12
15
  pythonpath = ["trainings_pack"] # PYTHONPATH of the live process, relative to the snapshot
13
16
  lock_exclude = ["trainings-pack"] # local packages kept out of the live image's requirements
14
17
  lock_no_sources = false # true: requirements resolved without [tool.uv.sources] (git packs)
@@ -26,6 +29,7 @@
26
29
  exclude = ["trainings/CHAT1D/pack"] # removed from the snapshot
27
30
  explicit = false # true: published only when named (a home page)
28
31
  zoom = 80 # the HTML face opens as at this browser zoom, in percent (optional)
32
+ media_keep = ["downloads/chat1d/*"] # served by this site besides [release] media_keep (#142)
29
33
  """
30
34
 
31
35
  from __future__ import annotations
@@ -41,10 +45,10 @@ from . import ReleaseError
41
45
 
42
46
  FACES = ("live", "html")
43
47
  SITE_RE = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$")
44
- _SITE_KEYS = {"site", "book", "group", "faces", "paths", "exclude", "explicit", "legacy", "zoom"}
48
+ _SITE_KEYS = {"site", "book", "group", "faces", "paths", "exclude", "explicit", "legacy", "zoom", "media_keep"}
45
49
  _RELEASE_KEYS = {"project", "langs", "snapshot", "media", "media_exclude", "pythonpath", "lock_exclude",
46
50
  "lock_no_sources", "apt", "min_words", "port_base", "dir", "sites", "sites_from", "checks",
47
- "verify"}
51
+ "verify", "media_per_site", "media_keep"}
48
52
  _APT_RE = re.compile(r"^[a-z0-9][a-z0-9.+-]+$")
49
53
 
50
54
 
@@ -61,6 +65,7 @@ class Site:
61
65
  explicit: bool = False
62
66
  legacy: tuple[str, ...] = () # the prototype's (training, kind), for convert-passwords
63
67
  zoom: float | None = None # the HTML face's zoom in percent (stx export html --zoom)
68
+ media_keep: tuple[str, ...] = () # globs served by this site's live face besides its export's (#142)
64
69
 
65
70
  @property
66
71
  def doc_dir(self) -> str:
@@ -91,6 +96,8 @@ class ReleaseConfig:
91
96
  dir: str = ".stx-release"
92
97
  checks: str | None = None
93
98
  verify: str | None = None # the project's check of each HTML export (#144)
99
+ media_per_site: bool = False # the live face serves only the site's own media (#142)
100
+ media_keep: tuple[str, ...] = () # globs served by every site's live face besides its export's
94
101
  by_name: dict[str, Site] = field(default_factory=dict)
95
102
 
96
103
  def __post_init__(self):
@@ -139,6 +146,13 @@ def _call(spec: str, root: Path, what: str):
139
146
  sys.path.remove(str(root))
140
147
 
141
148
 
149
+ def _globs(raw: object, where: str) -> tuple[str, ...]:
150
+ if not isinstance(raw, list) or not all(isinstance(x, str) and x and not x.startswith("/") for x in raw):
151
+ raise ReleaseError(f"{where}: media_keep — a list of globs under the static folder, "
152
+ "such as [\"media/videos/*\"]")
153
+ return tuple(raw)
154
+
155
+
142
156
  def _site(raw: object, where: str) -> Site:
143
157
  if not isinstance(raw, dict):
144
158
  raise ReleaseError(f"{where}: a site is a table")
@@ -162,7 +176,8 @@ def _site(raw: object, where: str) -> Site:
162
176
  raise ReleaseError(f"{where} ({name}): zoom {zoom!r} — a positive percentage, such as 80")
163
177
  return Site(site=name, book=book, group=str(raw.get("group") or name), faces=faces, paths=paths,
164
178
  exclude=tuple(raw.get("exclude", ())), explicit=bool(raw.get("explicit", False)), legacy=legacy,
165
- zoom=float(zoom) if zoom is not None else None)
179
+ zoom=float(zoom) if zoom is not None else None,
180
+ media_keep=_globs(raw.get("media_keep", []), f"{where} ({name})"))
166
181
 
167
182
 
168
183
  def load_config(root: Path | None = None) -> ReleaseConfig:
@@ -201,6 +216,9 @@ def load_config(root: Path | None = None) -> ReleaseConfig:
201
216
  verify = data.get("verify")
202
217
  if verify is not None and not (isinstance(verify, str) and re.fullmatch(r"[\w.]+:\w+", verify)):
203
218
  raise ReleaseError(f"[release] verify = {verify!r} — expected 'module:function'")
219
+ per_site = data.get("media_per_site", False)
220
+ if not isinstance(per_site, bool):
221
+ raise ReleaseError(f"[release] media_per_site = {per_site!r} — true or false")
204
222
  return ReleaseConfig(
205
223
  root=root, project=project, sites=sites, langs=langs,
206
224
  snapshot=tuple(data.get("snapshot", ())), media=data.get("media"),
@@ -208,7 +226,8 @@ def load_config(root: Path | None = None) -> ReleaseConfig:
208
226
  pythonpath=tuple(data.get("pythonpath", ())), lock_exclude=tuple(data.get("lock_exclude", ())),
209
227
  lock_no_sources=bool(data.get("lock_no_sources", False)), apt=apt,
210
228
  min_words=int(data.get("min_words", 100)), port_base=int(data.get("port_base", 9000)),
211
- dir=str(data.get("dir", ".stx-release")), checks=data.get("checks"), verify=verify)
229
+ dir=str(data.get("dir", ".stx-release")), checks=data.get("checks"), verify=verify,
230
+ media_per_site=per_site, media_keep=_globs(data.get("media_keep", []), "[release]"))
212
231
 
213
232
 
214
233
  def run_checks(cfg: ReleaseConfig, sites: list[Site]) -> None:
@@ -0,0 +1,153 @@
1
+ """Media per site (#142): the live face serves only the files its site shows.
2
+
3
+ The router serves ``/app/static/`` from ``live/sites/<site>/static``, a link to the site's
4
+ ``<doc>/static`` folder — in a project whose documents share one media folder, every file of every
5
+ document. With ``[release] media_per_site = true``, each release of the code holds a folder
6
+ ``.stx-served`` of links to the files the site uses, and the router serves that folder instead:
7
+
8
+ * the files its HTML export shows: each file of the export's ``data/`` folders, found again in the
9
+ static folder by its content (size, then SHA-256) — the export copies a served file unchanged;
10
+ * the files ``media_keep`` names (``[release]`` and the site's table): glob patterns relative to the
11
+ static folder, as in ``/app/static/<path>``; ``*`` also crosses ``/``.
12
+
13
+ The code itself is unchanged: a block that reads a file of the static folder still finds it. A site
14
+ without an HTML face keeps serving its whole static folder. ``stx release media-report SITE`` shows
15
+ the plan without changing anything.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import os
22
+ import re
23
+ from collections import defaultdict
24
+ from dataclasses import dataclass, field
25
+ from fnmatch import fnmatchcase
26
+ from pathlib import Path
27
+
28
+ from .config import ReleaseConfig, Site
29
+
30
+ SERVED = ".stx-served"
31
+
32
+ #: A path under the static folder named in a site's own files: ``static/…`` or ``app/static/…``.
33
+ _NAMED_RE = re.compile(r"""(?:app/)?static/([\w.~/-]+)""")
34
+ _TEXT = {".py", ".md", ".html", ".css", ".js", ".toml", ".json", ".txt", ".yaml", ".yml"}
35
+
36
+
37
+ @dataclass
38
+ class MediaPlan:
39
+ """What the live face of one site serves."""
40
+
41
+ static: Path | None # the site's static folder, resolved (None: it has none)
42
+ files: dict[str, int] = field(default_factory=dict) # every file of it → its size
43
+ kept: dict[str, str] = field(default_factory=dict) # served → why: "export" or the pattern
44
+ unmatched: int = 0 # export files found in no static file (inlined, generated)
45
+ named: list[str] = field(default_factory=list) # named in the site's files, not kept
46
+
47
+ @property
48
+ def dropped(self) -> list[str]:
49
+ return sorted(set(self.files) - set(self.kept))
50
+
51
+ def size(self, paths) -> int:
52
+ return sum(self.files[p] for p in paths)
53
+
54
+
55
+ def _walk(root: Path) -> dict[str, int]:
56
+ out: dict[str, int] = {}
57
+ for d, dirs, names in os.walk(root, followlinks=True):
58
+ dirs[:] = [x for x in dirs if x != SERVED]
59
+ for n in names:
60
+ p = Path(d) / n
61
+ try:
62
+ out[p.relative_to(root).as_posix()] = p.stat().st_size
63
+ except OSError: # a broken link: nothing to serve
64
+ pass
65
+ return out
66
+
67
+
68
+ def _digest(p: Path) -> str:
69
+ h = hashlib.sha256()
70
+ with open(p, "rb") as f:
71
+ for chunk in iter(lambda: f.read(1 << 20), b""):
72
+ h.update(chunk)
73
+ return h.hexdigest()
74
+
75
+
76
+ def keep_patterns(cfg: ReleaseConfig, site: Site) -> tuple[str, ...]:
77
+ return (*cfg.media_keep, *site.media_keep)
78
+
79
+
80
+ def plan(cfg: ReleaseConfig, site: Site, code: Path, html: Path) -> MediaPlan:
81
+ """The files the live face of *site* serves, from its snapshot *code* and its HTML export *html*,
82
+ both on this machine; the snapshot's media folder is linked to the project's while it is read."""
83
+ linked = None
84
+ if cfg.media and not (code / cfg.media).exists() and (cfg.root / cfg.media).is_dir():
85
+ linked = code / cfg.media
86
+ if linked.is_symlink(): # a link left dangling
87
+ linked.unlink()
88
+ linked.parent.mkdir(parents=True, exist_ok=True)
89
+ linked.symlink_to(cfg.root / cfg.media)
90
+ try:
91
+ return _plan(cfg, site, code, html)
92
+ finally:
93
+ if linked is not None:
94
+ linked.unlink()
95
+
96
+
97
+ def _plan(cfg: ReleaseConfig, site: Site, code: Path, html: Path) -> MediaPlan:
98
+ folder = code / site.doc_dir / "static"
99
+ if not folder.is_dir():
100
+ return MediaPlan(static=None)
101
+ mp = MediaPlan(static=folder.resolve(), files=_walk(folder))
102
+ by_size: dict[int, list[str]] = defaultdict(list)
103
+ for rel, size in mp.files.items():
104
+ by_size[size].append(rel)
105
+ digests: dict[str, str] = {}
106
+ for f in sorted(html.rglob("*")):
107
+ if not f.is_file() or "data" not in f.relative_to(html).parts[:-1]:
108
+ continue
109
+ same = by_size.get(f.stat().st_size, [])
110
+ found = False
111
+ if same:
112
+ want = _digest(f)
113
+ for rel in same:
114
+ if rel not in digests:
115
+ digests[rel] = _digest(folder / rel)
116
+ if digests[rel] == want:
117
+ mp.kept.setdefault(rel, "export")
118
+ found = True
119
+ mp.unmatched += not found
120
+ for pat in keep_patterns(cfg, site):
121
+ for rel in mp.files:
122
+ if fnmatchcase(rel, pat):
123
+ mp.kept.setdefault(rel, pat)
124
+ named = set()
125
+ for d, dirs, names in os.walk(code / site.doc_dir):
126
+ dirs[:] = [x for x in dirs if x not in ("static", SERVED, "__pycache__")]
127
+ for n in names:
128
+ if Path(n).suffix in _TEXT:
129
+ text = (Path(d) / n).read_text(encoding="utf-8", errors="replace")
130
+ named.update(m.rstrip("/.") for m in _NAMED_RE.findall(text))
131
+ mp.named = sorted(p for p in named if p in mp.files and p not in mp.kept)
132
+ return mp
133
+
134
+
135
+ def build_served(code: Path, site: Site, kept) -> Path:
136
+ """The folder ``.stx-served`` of the snapshot: one relative link per kept file, to the same file
137
+ under ``<doc>/static`` — the links resolve on the server as they do here."""
138
+ served = code / SERVED
139
+ static = code / site.doc_dir / "static"
140
+ for rel in sorted(kept):
141
+ link = served / rel
142
+ link.parent.mkdir(parents=True, exist_ok=True)
143
+ link.symlink_to(os.path.relpath(static / rel, link.parent))
144
+ served.mkdir(exist_ok=True) # no file kept: the folder exists, empty — nothing served
145
+ return served
146
+
147
+
148
+ def human(n: int) -> str:
149
+ for unit in ("B", "KB", "MB", "GB"):
150
+ if n < 1024 or unit == "GB":
151
+ return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
152
+ n /= 1024
153
+ return f"{n} B"
@@ -11,6 +11,7 @@ On the target (``<root>`` of the target, ``/srv/<face>`` in the containers)::
11
11
  live/sites/<site>/current -> releases/<release>
12
12
  live/sites/<site>/pass the live face's records
13
13
  live/sites/<site>/static -> current/<doc>/static the site's media, served by the router
14
+ -> current/.stx-served with media_per_site: its own media alone (#142)
14
15
  live/media/<project>/<media>/ the project's media, shared by every snapshot
15
16
  live/conf/programs/<site>.conf the site's supervisord program
16
17
  live/conf/ports.map site → port, read by the router
@@ -38,9 +39,10 @@ from pathlib import Path
38
39
 
39
40
  from . import ReleaseError, build
40
41
  from . import levels as lv
42
+ from . import media as md
41
43
  from . import passwords as pw
42
- from .config import ReleaseConfig, Site
43
- from .state import save_state, sites_of
44
+ from .config import SITE_RE, ReleaseConfig, Site
45
+ from .state import STATE_FILE, save_state, sites_of
44
46
  from .target import LIVE_ROOT, Target
45
47
 
46
48
  TEMPLATES = Path(__file__).parent / "templates"
@@ -78,9 +80,21 @@ def html_of(v: dict) -> str | None:
78
80
 
79
81
 
80
82
  def switch(t: Target, site: Site, release: str, faces: list[str] | None = None) -> None:
81
- """Each face's ``current`` link on *release*."""
82
- for face in faces or _faces_dirs(site):
83
+ """Each face's ``current`` link on *release*; the live face's media follow it."""
84
+ dirs = faces or _faces_dirs(site)
85
+ for face in dirs:
83
86
  t.sh(t.switch_cmd(t.path(face, "sites", site.site), release))
87
+ if "live" in dirs:
88
+ link_static(t, site)
89
+
90
+
91
+ def link_static(t: Target, site: Site) -> None:
92
+ """The media the router serves for *site*: the release's ``.stx-served`` when it has one (#142),
93
+ else its whole ``<doc>/static`` — a rollback to a release of before brings its media back."""
94
+ cur = f"{LIVE_ROOT}/sites/{site.site}/current"
95
+ t.sh(f"cd {shlex.quote(t.path('live', 'sites', site.site))} && "
96
+ f"if [ -d current/{md.SERVED} ]; then ln -sfn {shlex.quote(f'{cur}/{md.SERVED}')} static; "
97
+ f"else ln -sfn {shlex.quote(f'{cur}/{site.doc_dir}/static')} static; fi")
84
98
 
85
99
 
86
100
  def server_records(t: Target) -> str:
@@ -288,6 +302,18 @@ def publish_site(t: Target, cfg: ReleaseConfig, state: dict, site: Site, release
288
302
  build.verify_export(cfg, site, html)
289
303
  if cfg.verify:
290
304
  echo(f" ✓ export accepted by {cfg.verify}")
305
+ if cfg.media_per_site and "live" in site.faces:
306
+ if html is None:
307
+ echo(f" ! media per site: {site.site} has no HTML face — its live face serves its whole "
308
+ "static folder")
309
+ elif (mp := md.plan(cfg, site, code, html)).static is not None:
310
+ md.build_served(code, site, mp.kept)
311
+ echo(f" ✓ media per site: {len(mp.kept)} of {len(mp.files)} file(s) served "
312
+ f"({md.human(mp.size(mp.kept))})")
313
+ if mp.named:
314
+ echo(f" ! named in the site's files, not served: {', '.join(mp.named[:5])}"
315
+ + (f" (+{len(mp.named) - 5})" if len(mp.named) > 5 else "")
316
+ + " — add them to media_keep")
291
317
  if "live" in site.faces:
292
318
  dirs = [t.path("live", "sites", site.site, "releases")]
293
319
  if cfg.media:
@@ -315,9 +341,6 @@ def publish_site(t: Target, cfg: ReleaseConfig, state: dict, site: Site, release
315
341
  media.symlink_to(f"{LIVE_ROOT}/media/{cfg.project}/{cfg.media}")
316
342
  t.rsync(str(code) + "/", t.path("live", "sites", site.site, "releases", release) + "/", "--delete")
317
343
  t.sh(f"chmod -R a+rX {shlex.quote(t.path('live', 'sites', site.site, 'releases', release))}")
318
- if "live" in site.faces:
319
- t.sh(f"ln -sfn {LIVE_ROOT}/sites/{site.site}/current/{site.doc_dir}/static "
320
- f"{shlex.quote(t.path('live', 'sites', site.site, 'static'))}")
321
344
  apply_passwords(t, cfg, state, site, faces, pool)
322
345
  write_maps(t, state)
323
346
  if "html" in site.faces:
@@ -452,6 +475,49 @@ def sleep_wake(t: Target, cfg: ReleaseConfig, state: dict, site: Site, asleep: b
452
475
  save_state(cfg.root, state)
453
476
 
454
477
 
478
+ def remove_plan(t: Target, state: dict, name: str) -> list[str]:
479
+ """What ``stx release remove`` takes off the target for site *name* (#143): ``[]`` when nothing."""
480
+ if not SITE_RE.match(name):
481
+ raise ReleaseError(f"{name!r} is not a site name")
482
+ out = []
483
+ for face in ("static", "live"):
484
+ d = t.path(face, "sites", name)
485
+ if t.sh(f"test -d {shlex.quote(d)}", check=False).returncode == 0:
486
+ out.append(f"folder {d}")
487
+ conf = t.path("live", "conf", "programs", f"{name}.conf")
488
+ if t.sh(f"test -f {shlex.quote(conf)}", check=False).returncode == 0:
489
+ out.append(f"program {conf} (stopped first)")
490
+ v = sites_of(state, t.name).get(name)
491
+ if v is not None:
492
+ if v.get("port"):
493
+ out.append(f"line '{name} {v['port']};' of {t.path('live', 'conf', 'ports.map')}")
494
+ out.append(f"entry {name} of {STATE_FILE} (target {t.name})")
495
+ return out
496
+
497
+
498
+ def remove_site(t: Target, cfg: ReleaseConfig, state: dict, name: str, echo: Echo) -> None:
499
+ """Take site *name* off the target: its program, its port, its folders and its state entry. No
500
+ other site is touched; the project's media stay (shared by every site)."""
501
+ if not SITE_RE.match(name):
502
+ raise ReleaseError(f"{name!r} is not a site name")
503
+ conf = t.path("live", "conf", "programs", f"{name}.conf")
504
+ t.dexec("live", f"supervisorctl stop {name}", check=False)
505
+ t.sh(f"rm -f {shlex.quote(conf)}")
506
+ sites_of(state, t.name).pop(name, None)
507
+ write_maps(t, state)
508
+ t.dexec("live", "supervisorctl reread", check=False)
509
+ t.dexec("live", "supervisorctl update", check=False)
510
+ t.dexec("static", "nginx -s reload", check=False)
511
+ t.dexec("live", "nginx -s reload", check=False)
512
+ for face in ("static", "live"):
513
+ d = t.path(face, "sites", name)
514
+ if not (SITE_RE.match(name) and d.rstrip("/").endswith(f"sites/{name}")):
515
+ raise ReleaseError(f"refused to remove {d!r}")
516
+ t.sh(f"rm -rf {shlex.quote(d)}")
517
+ save_state(cfg.root, state)
518
+ echo(f" ✓ {name} removed from {t.name}")
519
+
520
+
455
521
  def image_version(t: Target) -> str:
456
522
  """The streamtex version the live application actually runs, or ``""`` when it cannot be read."""
457
523
  r = t.dexec("live", "/app/.venv/bin/python -c 'import streamtex; print(streamtex.__version__)'", check=False)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: streamtex
3
- Version: 0.7.74
3
+ Version: 0.7.76
4
4
  Summary: AI-powered content framework for Streamlit — create presentations, courses, and web-books with Claude or Cursor, no coding required.
5
5
  Author-email: Nicolas Guelfi <nicolas.guelfi@laposte.net>
6
6
  License-Expression: BUSL-1.1
@@ -155,6 +155,7 @@ streamtex/release/build.py
155
155
  streamtex/release/config.py
156
156
  streamtex/release/coolify.py
157
157
  streamtex/release/levels.py
158
+ streamtex/release/media.py
158
159
  streamtex/release/ops.py
159
160
  streamtex/release/passwords.py
160
161
  streamtex/release/state.py
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes