unaltraweb 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (444) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +20 -0
  3. data/Makefile +412 -0
  4. data/README.md +226 -0
  5. data/_config.yml +842 -0
  6. data/_data/i18n/ca.yml +278 -0
  7. data/_data/i18n/en.yml +278 -0
  8. data/_data/i18n/es.yml +278 -0
  9. data/_includes/audio.liquid +30 -0
  10. data/_includes/bib_search.liquid +5 -0
  11. data/_includes/blog-list.liquid +66 -0
  12. data/_includes/book-citation.liquid +33 -0
  13. data/_includes/citation.liquid +26 -0
  14. data/_includes/custom_head.liquid +3 -0
  15. data/_includes/custom_scripts.liquid +5 -0
  16. data/_includes/cv/list.liquid +5 -0
  17. data/_includes/cv/list_groups.liquid +51 -0
  18. data/_includes/cv/map.liquid +29 -0
  19. data/_includes/cv/nested_list.liquid +16 -0
  20. data/_includes/cv/time_table.liquid +118 -0
  21. data/_includes/cv-download-card.liquid +26 -0
  22. data/_includes/developer-mode-switcher.liquid +24 -0
  23. data/_includes/disqus.liquid +13 -0
  24. data/_includes/distill_scripts.liquid +298 -0
  25. data/_includes/documentation-download-detail.liquid +76 -0
  26. data/_includes/documentation-downloads.liquid +71 -0
  27. data/_includes/documentation-profile-attributes.liquid +4 -0
  28. data/_includes/documentation-section-toc.liquid +9 -0
  29. data/_includes/documentation-sidebar.liquid +112 -0
  30. data/_includes/figure.liquid +87 -0
  31. data/_includes/footer.liquid +66 -0
  32. data/_includes/giscus.liquid +25 -0
  33. data/_includes/head.liquid +220 -0
  34. data/_includes/header.liquid +544 -0
  35. data/_includes/latest_posts.liquid +61 -0
  36. data/_includes/localized-date.liquid +5 -0
  37. data/_includes/manual-bibliography.liquid +2 -0
  38. data/_includes/manual-featured-readings.liquid +12 -0
  39. data/_includes/manual-other-readings.liquid +12 -0
  40. data/_includes/manual-page-toc.liquid +11 -0
  41. data/_includes/manual-sidebar.liquid +58 -0
  42. data/_includes/manual-teachers.liquid +74 -0
  43. data/_includes/metadata.liquid +278 -0
  44. data/_includes/news.liquid +48 -0
  45. data/_includes/newsletter.liquid +68 -0
  46. data/_includes/page-hero.liquid +96 -0
  47. data/_includes/page-link.liquid +8 -0
  48. data/_includes/pagination.liquid +22 -0
  49. data/_includes/post-reading-time.liquid +29 -0
  50. data/_includes/previous-next.liquid +37 -0
  51. data/_includes/profile-card.liquid +49 -0
  52. data/_includes/profile-feature-matrix.liquid +75 -0
  53. data/_includes/profile-highlights.liquid +122 -0
  54. data/_includes/project-grid.liquid +26 -0
  55. data/_includes/project-resource-link.liquid +45 -0
  56. data/_includes/project-resources.liquid +21 -0
  57. data/_includes/projects.liquid +38 -0
  58. data/_includes/projects_horizontal.liquid +37 -0
  59. data/_includes/publications-metrics-summary.liquid +69 -0
  60. data/_includes/publications-summary.liquid +127 -0
  61. data/_includes/reading-biblio-controls.liquid +65 -0
  62. data/_includes/reading-cover-card.liquid +93 -0
  63. data/_includes/reading-rating.liquid +11 -0
  64. data/_includes/recommended-readings.liquid +30 -0
  65. data/_includes/related_posts.liquid +39 -0
  66. data/_includes/repository/repo.liquid +48 -0
  67. data/_includes/repository/repo_trophies.liquid +44 -0
  68. data/_includes/repository/repo_user.liquid +35 -0
  69. data/_includes/resume/awards.liquid +19 -0
  70. data/_includes/resume/basics.liquid +28 -0
  71. data/_includes/resume/certificates.liquid +35 -0
  72. data/_includes/resume/education.liquid +54 -0
  73. data/_includes/resume/interests.liquid +34 -0
  74. data/_includes/resume/languages.liquid +31 -0
  75. data/_includes/resume/projects.liquid +32 -0
  76. data/_includes/resume/publications.liquid +28 -0
  77. data/_includes/resume/references.liquid +31 -0
  78. data/_includes/resume/skills.liquid +33 -0
  79. data/_includes/resume/volunteer.liquid +52 -0
  80. data/_includes/resume/work.liquid +52 -0
  81. data/_includes/scripts.liquid +364 -0
  82. data/_includes/search-data.js.liquid +341 -0
  83. data/_includes/selected_papers.liquid +3 -0
  84. data/_includes/t.liquid +31 -0
  85. data/_includes/team-grid.liquid +172 -0
  86. data/_includes/video.liquid +97 -0
  87. data/_layouts/about.liquid +93 -0
  88. data/_layouts/archive.liquid +45 -0
  89. data/_layouts/bib.liquid +677 -0
  90. data/_layouts/book-review.liquid +225 -0
  91. data/_layouts/book-shelf.liquid +66 -0
  92. data/_layouts/cv.liquid +127 -0
  93. data/_layouts/default.liquid +70 -0
  94. data/_layouts/distill.liquid +132 -0
  95. data/_layouts/documentation-download-detail.liquid +5 -0
  96. data/_layouts/documentation-home.liquid +100 -0
  97. data/_layouts/documentation-page.liquid +45 -0
  98. data/_layouts/manual-bib.liquid +73 -0
  99. data/_layouts/manual-chapter.liquid +99 -0
  100. data/_layouts/manual-featured-bib.liquid +100 -0
  101. data/_layouts/manual-home.liquid +120 -0
  102. data/_layouts/none.liquid +1 -0
  103. data/_layouts/outputs.liquid +29 -0
  104. data/_layouts/page.liquid +52 -0
  105. data/_layouts/post.liquid +95 -0
  106. data/_layouts/profile.liquid +36 -0
  107. data/_layouts/profiles.liquid +36 -0
  108. data/_layouts/project.liquid +7 -0
  109. data/_layouts/repositories.liquid +58 -0
  110. data/_layouts/theses.liquid +126 -0
  111. data/_plugins/bibliography_profiles.rb +164 -0
  112. data/_plugins/callouts.rb +104 -0
  113. data/_plugins/code_blocks.rb +277 -0
  114. data/_plugins/computation_figure_images.rb +266 -0
  115. data/_plugins/computation_sources.rb +42 -0
  116. data/_plugins/content_search_index.rb +210 -0
  117. data/_plugins/details.rb +23 -0
  118. data/_plugins/external-posts.rb +124 -0
  119. data/_plugins/figure_captions.rb +800 -0
  120. data/_plugins/file-exists.rb +22 -0
  121. data/_plugins/google-scholar-citations.rb +86 -0
  122. data/_plugins/hide-custom-bibtex.rb +18 -0
  123. data/_plugins/inspirehep-citations.rb +57 -0
  124. data/_plugins/localized_visual_sources.rb +145 -0
  125. data/_plugins/manual_release_metadata.rb +47 -0
  126. data/_plugins/mermaid_mmd_images.rb +233 -0
  127. data/_plugins/profile-pages.rb +28 -0
  128. data/_plugins/remove-accents.rb +32 -0
  129. data/_plugins/reproducible_build_time.rb +55 -0
  130. data/_plugins/search-data.rb +83 -0
  131. data/_plugins/theme-cache-bust.rb +56 -0
  132. data/_plugins/vega_visual_images.rb +290 -0
  133. data/_plugins/web_capture_images.rb +154 -0
  134. data/_sass/_base.scss +2133 -0
  135. data/_sass/_blog.scss +67 -0
  136. data/_sass/_callouts.scss +100 -0
  137. data/_sass/_custom.scss +437 -0
  138. data/_sass/_cv.scss +274 -0
  139. data/_sass/_developer.scss +97 -0
  140. data/_sass/_distill.scss +185 -0
  141. data/_sass/_documentation.scss +2220 -0
  142. data/_sass/_layout.scss +218 -0
  143. data/_sass/_manual.scss +2691 -0
  144. data/_sass/_profile.scss +323 -0
  145. data/_sass/_projects.scss +163 -0
  146. data/_sass/_reading.scss +465 -0
  147. data/_sass/_site-custom.scss +3 -0
  148. data/_sass/_tabs.scss +48 -0
  149. data/_sass/_team.scss +230 -0
  150. data/_sass/_themes.scss +254 -0
  151. data/_sass/_typograms.scss +132 -0
  152. data/_sass/_variables.scss +52 -0
  153. data/_sass/font-awesome/_animated.scss +152 -0
  154. data/_sass/font-awesome/_bordered-pulled.scss +20 -0
  155. data/_sass/font-awesome/_core.scss +49 -0
  156. data/_sass/font-awesome/_fixed-width.scss +7 -0
  157. data/_sass/font-awesome/_functions.scss +57 -0
  158. data/_sass/font-awesome/_icons.scss +13 -0
  159. data/_sass/font-awesome/_list.scss +18 -0
  160. data/_sass/font-awesome/_mixins.scss +65 -0
  161. data/_sass/font-awesome/_rotated-flipped.scss +31 -0
  162. data/_sass/font-awesome/_screen-reader.scss +14 -0
  163. data/_sass/font-awesome/_shims.scss +1578 -0
  164. data/_sass/font-awesome/_sizing.scss +16 -0
  165. data/_sass/font-awesome/_stacked.scss +32 -0
  166. data/_sass/font-awesome/_variables.scss +5044 -0
  167. data/_sass/font-awesome/brands.scss +30 -0
  168. data/_sass/font-awesome/fontawesome.scss +21 -0
  169. data/_sass/font-awesome/regular.scss +27 -0
  170. data/_sass/font-awesome/solid.scss +27 -0
  171. data/_sass/font-awesome/v4-shims.scss +11 -0
  172. data/_sass/tabler-icons/tabler-icons-filled.scss +1646 -0
  173. data/_sass/tabler-icons/tabler-icons-outline.scss +9859 -0
  174. data/_sass/tabler-icons/tabler-icons.scss +11463 -0
  175. data/_scripts/cronitor-analytics-setup.js +9 -0
  176. data/_scripts/giscus-setup.js +48 -0
  177. data/_scripts/google-analytics-setup.js +9 -0
  178. data/_scripts/open-panel-analytics-setup.js +14 -0
  179. data/_scripts/photoswipe-setup.js +11 -0
  180. data/assets/audio/epicaly-short-113909.mp3 +0 -0
  181. data/assets/bibliography/2018-12-22-distill.bib +7 -0
  182. data/assets/css/academicons.min.css +1 -0
  183. data/assets/css/bootstrap-toc.min.css +4 -0
  184. data/assets/css/bootstrap.min.css +7 -0
  185. data/assets/css/bootstrap.min.css.map +1 -0
  186. data/assets/css/jekyll-pygments-themes-github.css +209 -0
  187. data/assets/css/jekyll-pygments-themes-native.css +232 -0
  188. data/assets/css/jupyter-grade3.css +3758 -0
  189. data/assets/css/jupyter-monokai.css +3758 -0
  190. data/assets/css/jupyter.css +383 -0
  191. data/assets/css/main.scss +36 -0
  192. data/assets/css/mdb.min.css +48 -0
  193. data/assets/css/mdb.min.css.map +1 -0
  194. data/assets/css/scholar-icons.css +386 -0
  195. data/assets/fonts/academicons.ttf +0 -0
  196. data/assets/fonts/academicons.woff +0 -0
  197. data/assets/fonts/scholar-icons.ttf +0 -0
  198. data/assets/fonts/scholar-icons.woff +0 -0
  199. data/assets/fonts/tabler-icons-filled.ttf +0 -0
  200. data/assets/fonts/tabler-icons-filled.woff +0 -0
  201. data/assets/fonts/tabler-icons-filled.woff2 +0 -0
  202. data/assets/fonts/tabler-icons-outline.ttf +0 -0
  203. data/assets/fonts/tabler-icons-outline.woff +0 -0
  204. data/assets/fonts/tabler-icons-outline.woff2 +0 -0
  205. data/assets/fonts/tabler-icons.ttf +0 -0
  206. data/assets/fonts/tabler-icons.woff +0 -0
  207. data/assets/fonts/tabler-icons.woff2 +0 -0
  208. data/assets/html/relativity.html +15 -0
  209. data/assets/img/1.jpg +0 -0
  210. data/assets/img/10.jpg +0 -0
  211. data/assets/img/11.jpg +0 -0
  212. data/assets/img/12.jpg +0 -0
  213. data/assets/img/2.jpg +0 -0
  214. data/assets/img/3.jpg +0 -0
  215. data/assets/img/4.jpg +0 -0
  216. data/assets/img/5.jpg +0 -0
  217. data/assets/img/6.jpg +0 -0
  218. data/assets/img/7.jpg +0 -0
  219. data/assets/img/8.jpg +0 -0
  220. data/assets/img/9.jpg +0 -0
  221. data/assets/img/book_covers/the_godfather.jpg +0 -0
  222. data/assets/img/brand/dosquartsdedocs-logo-cafe.svg +31 -0
  223. data/assets/img/brand/dosquartsdedocs-logo-footer-cafe.svg +31 -0
  224. data/assets/img/brand/dosquartsdedocs-logo-white.svg +31 -0
  225. data/assets/img/brand/dosquartsdedocs-logo.svg +31 -0
  226. data/assets/img/prof_pic.jpg +0 -0
  227. data/assets/img/prof_pic_color.png +0 -0
  228. data/assets/img/profile-placeholder.svg +8 -0
  229. data/assets/img/publication_preview/brownian-motion.gif +0 -0
  230. data/assets/img/publication_preview/goodchild1992-gis-cover.jpg +0 -0
  231. data/assets/img/publication_preview/gutierrez2020-jtrg-preview.jpg +0 -0
  232. data/assets/img/publication_preview/harvey1989-postmodernity-cover.jpg +0 -0
  233. data/assets/img/publication_preview/tobler1970-economic-geography-cover.jpg +0 -0
  234. data/assets/img/publication_preview/tuan1977-space-place-cover.jpg +0 -0
  235. data/assets/img/publication_preview/wave-mechanics.gif +0 -0
  236. data/assets/img/publication_preview/zaragozi2025-rooftop-pv-preview.jpg +0 -0
  237. data/assets/img/rhino.png +0 -0
  238. data/assets/img/team/aaron-gutierrez.png +0 -0
  239. data/assets/img/team/alba-font.png +0 -0
  240. data/assets/img/team/alba-viana.png +0 -0
  241. data/assets/img/team/alejandro-gonzalez.png +0 -0
  242. data/assets/img/team/ana-pastor.png +0 -0
  243. data/assets/img/team/antoni-domenech.png +0 -0
  244. data/assets/img/team/benito-zaragozi.png +0 -0
  245. data/assets/img/team/cinta-sanz.png +0 -0
  246. data/assets/img/team/daniel-miravet.png +0 -0
  247. data/assets/img/team/eugenia-altamirano.png +0 -0
  248. data/assets/img/team/fiammetta-brandajs.png +0 -0
  249. data/assets/img/team/francesc-gonzalez.png +0 -0
  250. data/assets/img/team/isabel-paulino.png +0 -0
  251. data/assets/img/team/joan-borras.png +0 -0
  252. data/assets/img/team/julie-wilson.png +0 -0
  253. data/assets/img/team/liliana-sole.png +0 -0
  254. data/assets/img/team/maria-casado.png +0 -0
  255. data/assets/img/team/maria-medina.png +0 -0
  256. data/assets/img/team/marta-nello.png +0 -0
  257. data/assets/img/team/mogrify +1 -0
  258. data/assets/img/team/oscar-saladie.png +0 -0
  259. data/assets/img/team/paolo-russo.png +0 -0
  260. data/assets/img/team/raquel-santos.png +0 -0
  261. data/assets/img/team/salvador-anton.png +0 -0
  262. data/assets/img/team/sunsi-huertas.png +0 -0
  263. data/assets/img/team/user-placeholder.png +0 -0
  264. data/assets/img/team/wilbert-den-hoed.png +0 -0
  265. data/assets/img/team/xavier-delclos.png +0 -0
  266. data/assets/img/template_error.png +0 -0
  267. data/assets/js/bibsearch.js +70 -0
  268. data/assets/js/bootstrap-toc.min.js +5 -0
  269. data/assets/js/bootstrap.bundle.min.js +7 -0
  270. data/assets/js/bootstrap.bundle.min.js.map +1 -0
  271. data/assets/js/callouts.js +115 -0
  272. data/assets/js/chartjs-setup.js +14 -0
  273. data/assets/js/common.js +117 -0
  274. data/assets/js/content-search-match.js +159 -0
  275. data/assets/js/content-search.js +441 -0
  276. data/assets/js/copy_code.js +60 -0
  277. data/assets/js/custom.js +6 -0
  278. data/assets/js/developer-mode.js +103 -0
  279. data/assets/js/diff2html-setup.js +20 -0
  280. data/assets/js/distillpub/overrides.js +27 -0
  281. data/assets/js/distillpub/template.v2.js +9616 -0
  282. data/assets/js/distillpub/template.v2.js.map +1 -0
  283. data/assets/js/distillpub/transforms.v2.js +14557 -0
  284. data/assets/js/distillpub/transforms.v2.js.map +1 -0
  285. data/assets/js/documentation.js +435 -0
  286. data/assets/js/echarts-setup.js +29 -0
  287. data/assets/js/highlight-search-term.js +110 -0
  288. data/assets/js/jupyter_new_tab.js +18 -0
  289. data/assets/js/leaflet-setup.js +22 -0
  290. data/assets/js/manual.js +308 -0
  291. data/assets/js/masonry.js +12 -0
  292. data/assets/js/mathjax-setup.js +46 -0
  293. data/assets/js/mermaid-setup.js +37 -0
  294. data/assets/js/newsletter.js +105 -0
  295. data/assets/js/no_defer.js +24 -0
  296. data/assets/js/plotly-setup.js +52 -0
  297. data/assets/js/profile-nav.js +92 -0
  298. data/assets/js/progress-bar.js +73 -0
  299. data/assets/js/pseudocode-setup.js +18 -0
  300. data/assets/js/search/base-styles.min.js +286 -0
  301. data/assets/js/search/command-score.min.js +7 -0
  302. data/assets/js/search/hotkeys-js/hotkeys.esm.min.js +18 -0
  303. data/assets/js/search/lit/decorators.min.js +7 -0
  304. data/assets/js/search/lit/directives/class-map.min.js +7 -0
  305. data/assets/js/search/lit/directives/join.min.js +7 -0
  306. data/assets/js/search/lit/directives/live.min.js +7 -0
  307. data/assets/js/search/lit/directives/ref.min.js +7 -0
  308. data/assets/js/search/lit/directives/repeat.min.js +7 -0
  309. data/assets/js/search/lit/directives/unsafe-html.min.js +7 -0
  310. data/assets/js/search/lit/index.min.js +7 -0
  311. data/assets/js/search/lit-element/lit-element.min.js +12 -0
  312. data/assets/js/search/lit-html/directives/class-map.min.js +22 -0
  313. data/assets/js/search/lit-html/directives/join.min.js +12 -0
  314. data/assets/js/search/lit-html/directives/live.min.js +28 -0
  315. data/assets/js/search/lit-html/directives/ref.min.js +32 -0
  316. data/assets/js/search/lit-html/directives/repeat.min.js +27 -0
  317. data/assets/js/search/lit-html/directives/unsafe-html.min.js +22 -0
  318. data/assets/js/search/lit-html/is-server.min.js +12 -0
  319. data/assets/js/search/lit-html/lit-html.min.js +12 -0
  320. data/assets/js/search/ninja-action.min.js +101 -0
  321. data/assets/js/search/ninja-footer.min.js +62 -0
  322. data/assets/js/search/ninja-header.min.js +78 -0
  323. data/assets/js/search/ninja-keys.min.js +39 -0
  324. data/assets/js/search/reactive-element/decorators/custom-element.min.js +12 -0
  325. data/assets/js/search/reactive-element/decorators/event-options.min.js +12 -0
  326. data/assets/js/search/reactive-element/decorators/property.min.js +22 -0
  327. data/assets/js/search/reactive-element/decorators/query-all.min.js +17 -0
  328. data/assets/js/search/reactive-element/decorators/query-assigned-elements.min.js +17 -0
  329. data/assets/js/search/reactive-element/decorators/query-assigned-nodes.min.js +17 -0
  330. data/assets/js/search/reactive-element/decorators/query-async.min.js +17 -0
  331. data/assets/js/search/reactive-element/decorators/query.min.js +17 -0
  332. data/assets/js/search/reactive-element/decorators/state.min.js +27 -0
  333. data/assets/js/search/reactive-element/reactive-element.min.js +17 -0
  334. data/assets/js/search-setup.js +17 -0
  335. data/assets/js/shortcut-key.js +11 -0
  336. data/assets/js/tabs.min.js +5 -0
  337. data/assets/js/theme.js +314 -0
  338. data/assets/js/tooltips-setup.js +3 -0
  339. data/assets/js/typograms.js +1341 -0
  340. data/assets/js/vanilla-back-to-top.min.js +1 -0
  341. data/assets/js/vega-setup.js +24 -0
  342. data/assets/js/venobox-setup.js +5 -0
  343. data/assets/js/wechat.js +14 -0
  344. data/assets/js/zoom.js +15 -0
  345. data/assets/json/resume.json +98 -0
  346. data/assets/json/table_data.json +128 -0
  347. data/assets/jupyter/blog.ipynb +48 -0
  348. data/assets/pdf/example_pdf.pdf +0 -0
  349. data/assets/plotly/demo.html +72 -0
  350. data/assets/video/pexels-engin-akyurt-6069112-960x540-30fps.mp4 +0 -0
  351. data/assets/webfonts/fa-brands-400.ttf +0 -0
  352. data/assets/webfonts/fa-brands-400.woff2 +0 -0
  353. data/assets/webfonts/fa-regular-400.ttf +0 -0
  354. data/assets/webfonts/fa-regular-400.woff2 +0 -0
  355. data/assets/webfonts/fa-solid-900.ttf +0 -0
  356. data/assets/webfonts/fa-solid-900.woff2 +0 -0
  357. data/assets/webfonts/fa-v4compatibility.ttf +0 -0
  358. data/assets/webfonts/fa-v4compatibility.woff2 +0 -0
  359. data/docs/Gemfile +5 -0
  360. data/docs/_config.yml +83 -0
  361. data/docs/_documentation/en/01-quick-start.md +115 -0
  362. data/docs/_documentation/en/02-tools.md +146 -0
  363. data/docs/_documentation/en/03-usage.md +107 -0
  364. data/docs/_documentation/en/04-jekyll-basics.md +51 -0
  365. data/docs/_documentation/en/05-pages-collections.md +68 -0
  366. data/docs/_documentation/en/06-github-web-editing.md +92 -0
  367. data/docs/_documentation/en/10-profiles.md +125 -0
  368. data/docs/_documentation/en/11-unaltreselfie.md +39 -0
  369. data/docs/_documentation/en/12-unaltreprojecte.md +38 -0
  370. data/docs/_documentation/en/13-unaltremanual.md +237 -0
  371. data/docs/_documentation/en/14-unaltredocs.md +92 -0
  372. data/docs/_documentation/en/15-profile-checklist.md +39 -0
  373. data/docs/_documentation/en/20-syntax.md +353 -0
  374. data/docs/_documentation/en/21-themes.md +103 -0
  375. data/docs/_documentation/en/22-feature-reference.md +56 -0
  376. data/docs/_documentation/en/23-front-matter.md +51 -0
  377. data/docs/_documentation/en/24-data-files.md +28 -0
  378. data/docs/_documentation/en/30-customization.md +448 -0
  379. data/docs/_documentation/en/31-template.md +55 -0
  380. data/docs/_documentation/en/32-development.md +108 -0
  381. data/docs/_documentation/en/33-reusable-workflows.md +136 -0
  382. data/docs/_documentation/en/40-distribution.md +164 -0
  383. data/docs/_documentation/en/41-bibliometrics.md +137 -0
  384. data/docs/_documentation/en/42-docker-image.md +185 -0
  385. data/docs/_documentation/en/50-faq.md +33 -0
  386. data/docs/_pages/ca/index.md +35 -0
  387. data/docs/_pages/en/index.md +35 -0
  388. data/docs/_pages/es/index.md +35 -0
  389. data/docs/agents/action-prompts/00-start-site-session.txt +10 -0
  390. data/docs/agents/action-prompts/05-create-new-web.txt +5 -0
  391. data/docs/agents/action-prompts/10-content-update.txt +9 -0
  392. data/docs/agents/action-prompts/15-edit-default-content.txt +8 -0
  393. data/docs/agents/action-prompts/20-manual-teaching-materials.txt +18 -0
  394. data/docs/agents/action-prompts/22-manual-style-audit.txt +14 -0
  395. data/docs/agents/action-prompts/23-manual-structure-audit.txt +17 -0
  396. data/docs/agents/action-prompts/25-translation-prepublish.txt +8 -0
  397. data/docs/agents/action-prompts/30-project-site-update.txt +7 -0
  398. data/docs/agents/action-prompts/40-documentation-update.txt +7 -0
  399. data/docs/agents/action-prompts/50-bibliography-entry.txt +7 -0
  400. data/docs/agents/action-prompts/60-bibliometrics-refresh.txt +7 -0
  401. data/docs/agents/action-prompts/70-build-and-review.txt +12 -0
  402. data/docs/agents/manual-authoring-components.md +255 -0
  403. data/docs/agents/mcp-contract.md +162 -0
  404. data/docs/agents/quarto-computation-adoption.md +102 -0
  405. data/docs/assets/img/unaltraweb-docs-hero.svg +36 -0
  406. data/lib/unaltraweb/version.rb +8 -0
  407. data/lib/unaltraweb.rb +31 -0
  408. data/requirements.txt +5 -0
  409. data/scripts/biblio/fetch_scimago_csv.sh +126 -0
  410. data/scripts/biblio/metrics_common.py +154 -0
  411. data/scripts/biblio/metrics_merge_meta.py +73 -0
  412. data/scripts/biblio/metrics_update.py +826 -0
  413. data/scripts/computations/python/Dockerfile +37 -0
  414. data/scripts/computations/r/Dockerfile +31 -0
  415. data/scripts/computations/render.py +1267 -0
  416. data/scripts/cv/render_pdf_preview.sh +36 -0
  417. data/scripts/deploy/publish_branch.sh +80 -0
  418. data/scripts/generate_logo_variants.py +47 -0
  419. data/scripts/manual/Dockerfile +31 -0
  420. data/scripts/manual/build_pdf.py +1780 -0
  421. data/scripts/manual/filters/bibliography.lua +164 -0
  422. data/scripts/manual/filters/code-blocks.lua +102 -0
  423. data/scripts/manual/filters/figure-captions.lua +35 -0
  424. data/scripts/manual/publish_release.sh +281 -0
  425. data/scripts/manual/templates/manual.tex +494 -0
  426. data/scripts/manual/verify_release_assets.py +650 -0
  427. data/scripts/test_gem_build.py +128 -0
  428. data/scripts/test_reproducible_jekyll_build.py +191 -0
  429. data/scripts/test_wheel_install.py +282 -0
  430. data/scripts/unaltraweb-docker-mount.sh +36 -0
  431. data/scripts/unaltraweb-mcp-bootstrap.sh +105 -0
  432. data/scripts/unaltraweb-mcp-cleanup.sh +73 -0
  433. data/scripts/unaltraweb-mcp-project-id.sh +34 -0
  434. data/scripts/validate_distribution.py +563 -0
  435. data/scripts/validate_workflows.py +1285 -0
  436. data/scripts/web_captures/Dockerfile +31 -0
  437. data/scripts/web_captures/capture.mjs +157 -0
  438. data/scripts/web_captures/package-lock.json +56 -0
  439. data/scripts/web_captures/package.json +9 -0
  440. data/scripts/web_captures/render.py +867 -0
  441. data/src/unaltraweb_mcp/component-contract.json +153 -0
  442. data/src/unaltraweb_mcp/component-contract.schema.json +87 -0
  443. data/src/unaltraweb_mcp/docker_mount.py +19 -0
  444. metadata +861 -0
@@ -0,0 +1,867 @@
1
+ #!/usr/bin/env python3
2
+ """Capture local web previews as versioned PNG and annotated SVG artefacts."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import base64
8
+ import contextlib
9
+ import datetime as dt
10
+ import fcntl
11
+ import hashlib
12
+ import html
13
+ import json
14
+ import os
15
+ import re
16
+ import secrets
17
+ import shutil
18
+ import struct
19
+ import subprocess
20
+ import sys
21
+ import tempfile
22
+ import xml.etree.ElementTree as ET
23
+ from pathlib import Path
24
+ from typing import Any
25
+ from urllib.parse import urlsplit
26
+
27
+ FACTORY_SRC = Path(__file__).resolve().parents[2] / "src"
28
+ sys.path.insert(0, str(FACTORY_SRC))
29
+
30
+ from unaltraweb_mcp.docker_mount import docker_bind_mount
31
+
32
+ try:
33
+ import yaml
34
+ except ImportError as exc: # pragma: no cover - supplied by project tooling
35
+ raise SystemExit("PyYAML is required for unaltraweb web captures.") from exc
36
+
37
+
38
+ LOCK_PATH = Path(".unaltraweb/web-captures.lock.json")
39
+ SOURCE_SUFFIXES = (".capture.yml", ".capture.yaml")
40
+ DEFAULT_IMAGE = "ghcr.io/dosquartsdedocs/unaltraweb-web-capture:0.3.0"
41
+ DEFAULT_VIEWPORT = {"width": 1440, "height": 900, "device_scale_factor": 1}
42
+ CAPTURE_ID_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,79}$")
43
+ SAFE_SOURCE_RE = re.compile(r"^[A-Za-z0-9_./-]+\.capture\.ya?ml$")
44
+ SVG_METADATA_RE = re.compile(r'<metadata\s+id="unaltraweb-capture">(.*?)</metadata>', re.DOTALL)
45
+ SVG_ALLOWED_ELEMENTS = {
46
+ "svg", "g", "defs", "metadata", "title", "desc", "image", "rect", "path", "text", "tspan",
47
+ "marker", "polygon", "polyline", "line", "circle", "ellipse", "clippath", "mask", "lineargradient",
48
+ "radialgradient", "stop", "pattern", "use", "namedview", "grid", "page",
49
+ }
50
+ SVG_ALLOWED_ATTRIBUTES = {
51
+ "id", "class", "version", "baseprofile", "x", "y", "x1", "y1", "x2", "y2", "cx", "cy", "r", "rx", "ry",
52
+ "width", "height", "viewbox", "preserveaspectratio", "d", "points", "transform", "fill", "stroke", "stroke-width",
53
+ "stroke-linecap", "stroke-linejoin", "stroke-miterlimit", "stroke-dasharray", "stroke-dashoffset", "opacity", "fill-opacity",
54
+ "stroke-opacity", "fill-rule", "clip-rule", "clip-path", "mask", "font-family", "font-size", "font-style", "font-weight",
55
+ "text-anchor", "dominant-baseline", "marker-start", "marker-mid", "marker-end", "markerwidth", "markerheight", "refx", "refy",
56
+ "orient", "offset", "stop-color", "stop-opacity", "patternunits", "patterncontentunits", "gradientunits", "gradienttransform",
57
+ "spreadmethod", "href", "style", "data-selector", "groupmode", "label", "space", "role", "aria-label", "pagecolor",
58
+ "bordercolor", "borderopacity", "objecttolerance", "gridtolerance", "guidetolerance", "showgrid", "showguides", "zoom",
59
+ "current-layer", "document-units", "pagecheckerboard", "deskcolor", "units", "originx", "originy", "spacingx", "spacingy",
60
+ }
61
+ SVG_ALLOWED_STYLE_PROPERTIES = {
62
+ "fill", "stroke", "stroke-width", "stroke-linecap", "stroke-linejoin", "stroke-miterlimit", "stroke-dasharray",
63
+ "stroke-dashoffset", "opacity", "fill-opacity", "stroke-opacity", "fill-rule", "clip-rule", "clip-path", "mask",
64
+ "font-family", "font-size", "font-style", "font-weight", "text-anchor", "dominant-baseline", "marker-start", "marker-mid",
65
+ "marker-end", "stop-color", "stop-opacity", "display", "visibility",
66
+ }
67
+
68
+
69
+ class WebCaptureError(RuntimeError):
70
+ pass
71
+
72
+
73
+ def utc_now() -> str:
74
+ return dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat()
75
+
76
+
77
+ def json_dump(value: Any) -> str:
78
+ return json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
79
+
80
+
81
+ def canonical_json(value: Any) -> bytes:
82
+ return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
83
+
84
+
85
+ def sha256_bytes(value: bytes) -> str:
86
+ return hashlib.sha256(value).hexdigest()
87
+
88
+
89
+ def file_signature(path: Path) -> dict[str, Any]:
90
+ data = path.read_bytes()
91
+ return {"sha256": sha256_bytes(data), "size": len(data)}
92
+
93
+
94
+ def path_signatures(project: Path, path: Path) -> list[dict[str, str]]:
95
+ files = [path] if path.is_file() else sorted(item for item in path.rglob("*") if item.is_file())
96
+ return [{"path": relative(project, item), "sha256": file_signature(item)["sha256"]} for item in files]
97
+
98
+
99
+ def safe_relative(project: Path, raw: str, *, label: str, must_exist: bool = False) -> Path:
100
+ value = str(raw or "").strip()
101
+ if not value:
102
+ raise WebCaptureError(f"No {label} path is configured.")
103
+ relative = Path(value)
104
+ if relative.is_absolute() or ".." in relative.parts:
105
+ raise WebCaptureError(f"{label} must be a project-relative path: {raw}")
106
+ resolved = (project / relative).resolve()
107
+ try:
108
+ resolved.relative_to(project)
109
+ except ValueError as exc:
110
+ raise WebCaptureError(f"{label} escapes the project: {raw}") from exc
111
+ if must_exist and not resolved.exists():
112
+ raise WebCaptureError(f"Missing {label}: {relative}")
113
+ return resolved
114
+
115
+
116
+ def relative(project: Path, path: Path) -> str:
117
+ return str(path.resolve().relative_to(project)).replace(os.sep, "/")
118
+
119
+
120
+ def validate_keys(mapping: dict[str, Any], allowed: set[str], *, label: str) -> None:
121
+ unknown = sorted(str(key) for key in mapping if key not in allowed)
122
+ if unknown:
123
+ raise WebCaptureError(f"Unknown {label} keys: {', '.join(unknown)}")
124
+
125
+
126
+ def mapping(value: Any, *, label: str) -> dict[str, Any]:
127
+ if value is None:
128
+ return {}
129
+ if not isinstance(value, dict):
130
+ raise WebCaptureError(f"{label} must be a mapping.")
131
+ return value
132
+
133
+
134
+ def bounded_int(value: Any, *, label: str, minimum: int, maximum: int) -> int:
135
+ if isinstance(value, bool) or not isinstance(value, int) or not minimum <= value <= maximum:
136
+ raise WebCaptureError(f"{label} must be an integer between {minimum} and {maximum}.")
137
+ return value
138
+
139
+
140
+ def validate_selector(value: Any, *, label: str) -> str:
141
+ selector = str(value or "").strip()
142
+ if not selector or len(selector) > 500 or any(character in selector for character in "\r\n\0"):
143
+ raise WebCaptureError(f"{label} must be a non-empty CSS selector of at most 500 characters.")
144
+ return selector
145
+
146
+
147
+ def validate_path(value: Any) -> str:
148
+ path = str(value or "").strip()
149
+ parsed = urlsplit(path)
150
+ if not path.startswith("/") or path.startswith("//") or parsed.scheme or parsed.netloc or parsed.fragment or any(ord(char) < 32 for char in path):
151
+ raise WebCaptureError("Capture path must be a local absolute URL path without a scheme, host, fragment, or control characters.")
152
+ return path
153
+
154
+
155
+ def normalize_viewport(raw: Any) -> dict[str, Any]:
156
+ viewport = {**DEFAULT_VIEWPORT, **mapping(raw, label="viewport")}
157
+ validate_keys(viewport, {"width", "height", "device_scale_factor"}, label="viewport")
158
+ width = bounded_int(viewport["width"], label="viewport width", minimum=320, maximum=4096)
159
+ height = bounded_int(viewport["height"], label="viewport height", minimum=240, maximum=4096)
160
+ scale = viewport["device_scale_factor"]
161
+ if scale not in {1, 2}:
162
+ raise WebCaptureError("viewport device_scale_factor must be 1 or 2.")
163
+ return {"width": width, "height": height, "device_scale_factor": scale}
164
+
165
+
166
+ def normalize_capture(raw: Any) -> dict[str, Any]:
167
+ capture = mapping(raw, label="capture")
168
+ validate_keys(capture, {"full_page", "selector", "clip", "padding"}, label="capture")
169
+ result: dict[str, Any] = {"full_page": bool(capture.get("full_page", False))}
170
+ if capture.get("selector"):
171
+ result["selector"] = validate_selector(capture["selector"], label="capture selector")
172
+ if capture.get("clip"):
173
+ clip = mapping(capture["clip"], label="capture clip")
174
+ validate_keys(clip, {"selector", "padding"}, label="capture clip")
175
+ result["clip"] = {
176
+ "selector": validate_selector(clip.get("selector"), label="capture clip selector"),
177
+ "padding": bounded_int(clip.get("padding", 0), label="capture clip padding", minimum=0, maximum=500),
178
+ }
179
+ result["padding"] = bounded_int(capture.get("padding", 0), label="capture padding", minimum=0, maximum=500)
180
+ modes = int(result["full_page"]) + int("selector" in result) + int("clip" in result)
181
+ if modes > 1:
182
+ raise WebCaptureError("capture full_page, selector, and clip are mutually exclusive.")
183
+ return result
184
+
185
+
186
+ def normalize_theme(raw: Any) -> dict[str, Any]:
187
+ theme = mapping(raw, label="theme")
188
+ validate_keys(theme, {"setting", "color_scheme", "expect"}, label="theme")
189
+ setting = str(theme.get("setting") or "").strip()
190
+ if len(setting) > 80 or any(ord(char) < 32 for char in setting):
191
+ raise WebCaptureError("theme setting is invalid.")
192
+ color_scheme = str(theme.get("color_scheme") or ("dark" if setting == "dark" else "light"))
193
+ if color_scheme not in {"light", "dark", "no-preference"}:
194
+ raise WebCaptureError("theme color_scheme must be light, dark, or no-preference.")
195
+ result: dict[str, Any] = {"setting": setting, "color_scheme": color_scheme, "expect": None}
196
+ if theme.get("expect"):
197
+ expect = mapping(theme["expect"], label="theme expect")
198
+ validate_keys(expect, {"selector", "attribute", "equals"}, label="theme expect")
199
+ attribute = str(expect.get("attribute") or "").strip()
200
+ equals = str(expect.get("equals") or "")
201
+ if not re.fullmatch(r"[A-Za-z_:][-A-Za-z0-9_:.]*", attribute):
202
+ raise WebCaptureError("theme expect attribute is invalid.")
203
+ result["expect"] = {
204
+ "selector": validate_selector(expect.get("selector"), label="theme expect selector"),
205
+ "attribute": attribute,
206
+ "equals": equals,
207
+ }
208
+ return result
209
+
210
+
211
+ def normalize_waits(raw: Any) -> dict[str, Any]:
212
+ waits = mapping(raw, label="waits")
213
+ validate_keys(waits, {"wait_until", "timeout_ms", "delay_ms", "fonts", "images", "selectors"}, label="waits")
214
+ wait_until = str(waits.get("wait_until") or "load")
215
+ if wait_until not in {"load", "domcontentloaded", "commit"}:
216
+ raise WebCaptureError("waits wait_until must be load, domcontentloaded, or commit.")
217
+ selectors = []
218
+ for index, raw_selector in enumerate(waits.get("selectors") or []):
219
+ item = {"selector": raw_selector} if isinstance(raw_selector, str) else mapping(raw_selector, label=f"wait selector {index}")
220
+ validate_keys(item, {"selector", "state"}, label=f"wait selector {index}")
221
+ state = str(item.get("state") or "visible")
222
+ if state not in {"attached", "detached", "visible", "hidden"}:
223
+ raise WebCaptureError(f"Invalid wait selector state: {state}")
224
+ selectors.append({"selector": validate_selector(item.get("selector"), label=f"wait selector {index}"), "state": state})
225
+ if len(selectors) > 100:
226
+ raise WebCaptureError("At most 100 wait selectors are allowed.")
227
+ return {
228
+ "wait_until": wait_until,
229
+ "timeout_ms": bounded_int(waits.get("timeout_ms", 30000), label="wait timeout", minimum=1000, maximum=120000),
230
+ "delay_ms": bounded_int(waits.get("delay_ms", 0), label="wait delay", minimum=0, maximum=10000),
231
+ "fonts": waits.get("fonts", True) is not False,
232
+ "images": waits.get("images", True) is not False,
233
+ "selectors": selectors,
234
+ }
235
+
236
+
237
+ def normalize_annotations(raw: Any) -> list[dict[str, Any]]:
238
+ if raw is None:
239
+ return []
240
+ if not isinstance(raw, list) or len(raw) > 100:
241
+ raise WebCaptureError("annotations must be a list of at most 100 items.")
242
+ annotations = []
243
+ for index, value in enumerate(raw):
244
+ item = mapping(value, label=f"annotation {index}")
245
+ validate_keys(item, {"id", "selector", "kind", "text", "padding", "color", "stroke_width", "position", "offset", "strict", "nth", "required"}, label=f"annotation {index}")
246
+ identifier = str(item.get("id") or f"annotation-{index + 1}")
247
+ if not CAPTURE_ID_RE.fullmatch(identifier):
248
+ raise WebCaptureError(f"Invalid annotation id: {identifier}")
249
+ kind = str(item.get("kind") or "outline")
250
+ if kind not in {"outline", "label", "arrow", "spotlight"}:
251
+ raise WebCaptureError(f"Invalid annotation kind: {kind}")
252
+ color = str(item.get("color") or "#b42318")
253
+ if not re.fullmatch(r"#[0-9A-Fa-f]{6}", color):
254
+ raise WebCaptureError(f"Annotation color must be #RRGGBB: {color}")
255
+ position = str(item.get("position") or "top-right")
256
+ if position not in {"top-left", "top-right", "bottom-left", "bottom-right", "left", "right"}:
257
+ raise WebCaptureError(f"Invalid annotation position: {position}")
258
+ offset = item.get("offset") or [0, 0]
259
+ if not isinstance(offset, list) or len(offset) != 2 or any(isinstance(part, bool) or not isinstance(part, (int, float)) or abs(part) > 2000 for part in offset):
260
+ raise WebCaptureError(f"Annotation offset must contain two bounded numbers: {identifier}")
261
+ annotations.append({
262
+ "id": identifier,
263
+ "selector": validate_selector(item.get("selector"), label=f"annotation {identifier} selector"),
264
+ "kind": kind,
265
+ "text": str(item.get("text") or ""),
266
+ "padding": bounded_int(item.get("padding", 6), label=f"annotation {identifier} padding", minimum=0, maximum=500),
267
+ "color": color.lower(),
268
+ "stroke_width": bounded_int(item.get("stroke_width", 3), label=f"annotation {identifier} stroke_width", minimum=1, maximum=20),
269
+ "position": position,
270
+ "offset": [float(offset[0]), float(offset[1])],
271
+ "strict": item.get("strict", True) is not False,
272
+ "nth": bounded_int(item.get("nth", 0), label=f"annotation {identifier} nth", minimum=0, maximum=1000),
273
+ "required": item.get("required", True) is not False,
274
+ })
275
+ return annotations
276
+
277
+
278
+ def read_recipe(project: Path, source: Path) -> dict[str, Any]:
279
+ try:
280
+ data = yaml.safe_load(source.read_text(encoding="utf-8"))
281
+ except yaml.YAMLError as exc:
282
+ raise WebCaptureError(f"Invalid capture recipe YAML in {relative(project, source)}: {exc}") from exc
283
+ if not isinstance(data, dict):
284
+ raise WebCaptureError(f"Capture recipe must be a YAML mapping: {relative(project, source)}")
285
+ validate_keys(data, {"version", "path", "viewport", "capture", "theme", "waits", "annotations", "inputs", "locale"}, label="capture recipe")
286
+ if data.get("version") != 1:
287
+ raise WebCaptureError(f"Unsupported capture recipe version in {relative(project, source)}")
288
+ inputs = data.get("inputs") or []
289
+ if not isinstance(inputs, list):
290
+ raise WebCaptureError("Capture recipe inputs must be a list.")
291
+ normalized_inputs = [relative(project, safe_relative(project, str(item), label="capture input", must_exist=True)) for item in inputs]
292
+ locale = str(data.get("locale") or "en-US")
293
+ if not re.fullmatch(r"[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*", locale):
294
+ raise WebCaptureError(f"Invalid capture locale: {locale}")
295
+ return {
296
+ "version": 1,
297
+ "path": validate_path(data.get("path")),
298
+ "viewport": normalize_viewport(data.get("viewport")),
299
+ "capture": normalize_capture(data.get("capture")),
300
+ "theme": normalize_theme(data.get("theme")),
301
+ "waits": normalize_waits(data.get("waits")),
302
+ "annotations": normalize_annotations(data.get("annotations")),
303
+ "inputs": normalized_inputs,
304
+ "locale": locale,
305
+ }
306
+
307
+
308
+ def output_paths(source: Path) -> tuple[Path, Path, Path]:
309
+ if not source.name.endswith(SOURCE_SUFFIXES):
310
+ raise WebCaptureError(f"Capture source must end with .capture.yml or .capture.yaml: {source}")
311
+ return source.with_suffix(".png"), source.with_suffix(".svg"), source.with_suffix(".edited.svg")
312
+
313
+
314
+ def discover(project: Path) -> list[dict[str, Any]]:
315
+ assets = project / "assets"
316
+ records = []
317
+ sources = sorted({source for suffix in SOURCE_SUFFIXES for source in assets.rglob(f"*{suffix}")}) if assets.is_dir() else []
318
+ for source in sources:
319
+ if any(part.startswith(".") for part in source.relative_to(project).parts):
320
+ continue
321
+ recipe = read_recipe(project, source)
322
+ png, svg, edited = output_paths(source)
323
+ records.append({"source": source, "source_path": relative(project, source), "recipe": recipe, "png": png, "svg": svg, "edited": edited})
324
+ return records
325
+
326
+
327
+ def selected_records(project: Path, source: str = "") -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
328
+ records = discover(project)
329
+ if not source:
330
+ return records, records
331
+ if not SAFE_SOURCE_RE.fullmatch(source) or source.startswith("/") or ".." in Path(source).parts:
332
+ raise WebCaptureError("Selected capture source must be a safe project-relative *.capture.yml or *.capture.yaml path.")
333
+ selected = safe_relative(project, source, label="selected capture source", must_exist=True)
334
+ matches = [record for record in records if record["source"] == selected]
335
+ if not matches:
336
+ raise WebCaptureError(f"Selected file is not a capture recipe under assets/: {source}")
337
+ return matches, records
338
+
339
+
340
+ def load_lock(project: Path) -> dict[str, Any]:
341
+ path = project / LOCK_PATH
342
+ if not path.is_file():
343
+ return {"version": 1, "records": {}}
344
+ try:
345
+ data = json.loads(path.read_text(encoding="utf-8"))
346
+ except json.JSONDecodeError as exc:
347
+ raise WebCaptureError(f"Invalid web capture lock file: {exc}") from exc
348
+ if not isinstance(data, dict) or data.get("version") != 1 or not isinstance(data.get("records"), dict):
349
+ raise WebCaptureError("Invalid web-captures.lock.json structure.")
350
+ return data
351
+
352
+
353
+ def write_lock(project: Path, lock: dict[str, Any]) -> None:
354
+ path = project / LOCK_PATH
355
+ path.parent.mkdir(parents=True, exist_ok=True)
356
+ lock["version"] = 1
357
+ descriptor, raw_path = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
358
+ temporary = Path(raw_path)
359
+ try:
360
+ with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
361
+ stream.write(json_dump(lock))
362
+ os.replace(temporary, path)
363
+ finally:
364
+ temporary.unlink(missing_ok=True)
365
+
366
+
367
+ def inspect_image(image: str) -> dict[str, str]:
368
+ configured_id = os.environ.get("UNALTRAWEB_CAPTURE_IMAGE_ID", "").strip()
369
+ configured_digest = os.environ.get("UNALTRAWEB_CAPTURE_IMAGE_DIGEST", "").strip()
370
+ if configured_id or configured_digest:
371
+ return {"available": "true", "id": configured_id, "digest": configured_digest}
372
+ if not shutil.which("docker"):
373
+ return {"available": "false", "id": "", "digest": ""}
374
+ completed = subprocess.run(["docker", "image", "inspect", image, "--format", '{{.Id}}|{{join .RepoDigests ","}}'], text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, check=False)
375
+ if completed.returncode != 0:
376
+ return {"available": "false", "id": "", "digest": ""}
377
+ image_id, _, digest = completed.stdout.strip().partition("|")
378
+ return {"available": "true", "id": image_id, "digest": digest}
379
+
380
+
381
+ def selected_image() -> str:
382
+ return os.environ.get("WEB_CAPTURE_IMAGE", "").strip() or DEFAULT_IMAGE
383
+
384
+
385
+ def factory_root() -> Path:
386
+ configured = os.environ.get("UNALTRAWEB_FACTORY_ROOT", "").strip()
387
+ return Path(configured).resolve() if configured else Path(__file__).resolve().parents[2]
388
+
389
+
390
+ def renderer_files() -> list[Path]:
391
+ root = factory_root()
392
+ return [
393
+ root / "scripts/web_captures/render.py",
394
+ root / "scripts/web_captures/capture.mjs",
395
+ root / "scripts/web_captures/Dockerfile",
396
+ root / "scripts/web_captures/package.json",
397
+ root / "scripts/web_captures/package-lock.json",
398
+ root / "_config.yml",
399
+ root / "_plugins",
400
+ root / "_layouts",
401
+ root / "_includes",
402
+ root / "_sass",
403
+ root / "assets/js",
404
+ ]
405
+
406
+
407
+ def fingerprint(project: Path, record: dict[str, Any], saved: dict[str, Any] | None = None) -> tuple[str, list[dict[str, str]], dict[str, str]]:
408
+ dependencies = [record["source"], *[safe_relative(project, item, label="capture input", must_exist=True) for item in record["recipe"]["inputs"]]]
409
+ signatures = [signature for path in dependencies for signature in path_signatures(project, path)]
410
+ root = factory_root()
411
+ renderer = [
412
+ {"path": str(item.relative_to(root)).replace(os.sep, "/"), "sha256": file_signature(item)["sha256"]}
413
+ for path in renderer_files() if path.exists()
414
+ for item in ([path] if path.is_file() else sorted(candidate for candidate in path.rglob("*") if candidate.is_file()))
415
+ ]
416
+ image = selected_image()
417
+ local_identity = inspect_image(image)
418
+ saved_identity = (saved or {}).get("image_identity") if isinstance((saved or {}).get("image_identity"), dict) else {}
419
+ identity = local_identity if local_identity["available"] == "true" else saved_identity
420
+ payload = {"recipe": record["recipe"], "dependencies": signatures, "renderer": renderer, "image": image, "image_identity": identity}
421
+ return sha256_bytes(canonical_json(payload)), signatures, {"image": image, **identity}
422
+
423
+
424
+ def signatures_match(path: Path, saved: Any) -> bool:
425
+ return isinstance(saved, dict) and path.is_file() and saved.get("sha256") == file_signature(path)["sha256"] and saved.get("size") == path.stat().st_size
426
+
427
+
428
+ def svg_metadata(path: Path) -> dict[str, Any] | None:
429
+ if not path.is_file():
430
+ return None
431
+ text = path.read_text(encoding="utf-8")
432
+ match = SVG_METADATA_RE.search(text)
433
+ if not match:
434
+ return None
435
+ try:
436
+ value = json.loads(html.unescape(match.group(1)))
437
+ except json.JSONDecodeError:
438
+ return None
439
+ return value if isinstance(value, dict) else None
440
+
441
+
442
+ def validate_svg_safety(path: Path) -> None:
443
+ text = path.read_text(encoding="utf-8")
444
+ lowered = text.lower()
445
+ if "<!doctype" in lowered or "<!entity" in lowered:
446
+ raise WebCaptureError(f"Unsafe executable content in capture SVG: {path}")
447
+ try:
448
+ root = ET.fromstring(text)
449
+ except ET.ParseError as exc:
450
+ raise WebCaptureError(f"Invalid capture SVG XML: {path}: {exc}") from exc
451
+ for element in root.iter():
452
+ tag = element.tag.rsplit("}", 1)[-1].lower()
453
+ if tag not in SVG_ALLOWED_ELEMENTS:
454
+ raise WebCaptureError(f"Unsupported element in capture SVG: {path}: {tag}")
455
+ for raw_name, raw_value in element.attrib.items():
456
+ name = raw_name.rsplit("}", 1)[-1].lower()
457
+ value = raw_value.strip()
458
+ lowered_value = value.lower()
459
+ if name not in SVG_ALLOWED_ATTRIBUTES:
460
+ raise WebCaptureError(f"Unsupported attribute in capture SVG: {path}: {name}")
461
+ if "@import" in lowered_value or "javascript:" in lowered_value or "expression(" in lowered_value:
462
+ raise WebCaptureError(f"Unsafe attribute in capture SVG: {path}: {name}")
463
+ if name == "href" and not (value.startswith("data:image/png;base64,") or value.startswith("#")):
464
+ raise WebCaptureError(f"External reference in capture SVG: {path}")
465
+ without_local_urls = re.sub(r"url\(\s*['\"]?#[A-Za-z_][-:.A-Za-z0-9_]*['\"]?\s*\)", "", lowered_value)
466
+ if "url(" in without_local_urls:
467
+ raise WebCaptureError(f"External CSS reference in capture SVG: {path}")
468
+ if name == "style":
469
+ declarations = [part.strip() for part in value.split(";") if part.strip()]
470
+ for declaration in declarations:
471
+ property_name, separator, _ = declaration.partition(":")
472
+ if not separator or property_name.strip().lower() not in SVG_ALLOWED_STYLE_PROPERTIES:
473
+ raise WebCaptureError(f"Unsupported style in capture SVG: {path}: {property_name.strip()}")
474
+
475
+
476
+ def edited_state(record: dict[str, Any], fingerprint_value: str = "") -> dict[str, Any]:
477
+ edited = record["edited"]
478
+ if not edited.is_file():
479
+ return {"status": "none", "path": ""}
480
+ validate_svg_safety(edited)
481
+ metadata = svg_metadata(edited)
482
+ png_hash = file_signature(record["png"])["sha256"] if record["png"].is_file() else ""
483
+ if not metadata or not metadata.get("png_sha256") or not metadata.get("fingerprint"):
484
+ return {"status": "invalid", "path": str(edited)}
485
+ current = metadata["png_sha256"] == png_hash and (not fingerprint_value or metadata["fingerprint"] == fingerprint_value)
486
+ return {"status": "current" if current else "stale", "path": str(edited), "png_sha256": metadata["png_sha256"], "fingerprint": metadata["fingerprint"]}
487
+
488
+
489
+ def orphaned_artifacts(project: Path, records: list[dict[str, Any]]) -> list[str]:
490
+ expected = {relative(project, path) for record in records for path in [record["png"], record["svg"], record["edited"]]}
491
+ assets = project / "assets"
492
+ actual = {
493
+ relative(project, path)
494
+ for pattern in ["*.capture.png", "*.capture.svg", "*.capture.edited.svg"]
495
+ for path in (assets.rglob(pattern) if assets.is_dir() else [])
496
+ }
497
+ return sorted(actual - expected)
498
+
499
+
500
+ def status(project: Path, source: str = "") -> dict[str, Any]:
501
+ selected, all_records = selected_records(project, source)
502
+ lock = load_lock(project)
503
+ items = []
504
+ for record in selected:
505
+ saved = lock["records"].get(record["source_path"], {})
506
+ current_fingerprint, dependencies, image = fingerprint(project, record, saved)
507
+ png_valid = signatures_match(record["png"], saved.get("png"))
508
+ svg_valid = signatures_match(record["svg"], saved.get("svg"))
509
+ if record["svg"].is_file():
510
+ validate_svg_safety(record["svg"])
511
+ edited = edited_state(record, current_fingerprint)
512
+ current = bool(saved and saved.get("fingerprint") == current_fingerprint and png_valid and svg_valid and edited["status"] in {"none", "current"})
513
+ reason = "current"
514
+ if not saved:
515
+ reason = "not_rendered"
516
+ elif saved.get("fingerprint") != current_fingerprint:
517
+ reason = "source_or_environment_changed"
518
+ elif not record["png"].is_file():
519
+ reason = "png_missing"
520
+ elif not png_valid:
521
+ reason = "png_modified"
522
+ elif not record["svg"].is_file():
523
+ reason = "svg_missing"
524
+ elif not svg_valid:
525
+ reason = "svg_modified"
526
+ elif edited["status"] == "stale":
527
+ reason = "edited_override_stale"
528
+ elif edited["status"] == "invalid":
529
+ reason = "edited_override_invalid"
530
+ effective = record["edited"] if record["edited"].is_file() else record["svg"]
531
+ items.append({
532
+ "source": record["source_path"],
533
+ "path": record["recipe"]["path"],
534
+ "png": relative(project, record["png"]),
535
+ "svg": relative(project, record["svg"]),
536
+ "edited_svg": relative(project, record["edited"]),
537
+ "effective_svg": relative(project, effective),
538
+ "edited": {**edited, "path": relative(project, record["edited"]) if record["edited"].is_file() else ""},
539
+ "image": image,
540
+ "dependencies": dependencies,
541
+ "fingerprint": current_fingerprint,
542
+ "current": current,
543
+ "reason": reason,
544
+ })
545
+ known = {record["source_path"] for record in all_records}
546
+ orphaned = sorted(path for path in lock["records"] if path not in known)
547
+ orphaned_files = orphaned_artifacts(project, all_records)
548
+ return {
549
+ "project": str(project),
550
+ "lock": str(LOCK_PATH) if (project / LOCK_PATH).is_file() else "",
551
+ "captures": items,
552
+ "capture_count": len(items),
553
+ "current_count": sum(1 for item in items if item["current"]),
554
+ "orphaned_records": orphaned,
555
+ "orphaned_artifacts": orphaned_files,
556
+ "ok": all(item["current"] for item in items) and not orphaned and not orphaned_files,
557
+ }
558
+
559
+
560
+ def validate_base_url(raw: str) -> str:
561
+ value = raw.strip().rstrip("/")
562
+ parsed = urlsplit(value)
563
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.username or parsed.password or parsed.query or parsed.fragment or parsed.path not in {"", "/"}:
564
+ raise WebCaptureError("Base URL must be an http(s) origin without credentials, path, query, or fragment.")
565
+ hostname = (parsed.hostname or "").lower()
566
+ service_host = os.environ.get("WEB_CAPTURE_SERVICE_HOST", "").strip().lower()
567
+ network = os.environ.get("WEB_CAPTURE_DOCKER_NETWORK", "").strip()
568
+ allowed = bool(service_host and hostname == service_host and network not in {"", "host", "none"})
569
+ if not allowed:
570
+ raise WebCaptureError("Base URL must use the declared service host on an isolated Docker network.")
571
+ if not internal_network(network):
572
+ raise WebCaptureError(f"Browser capture requires an internal Docker network: {network}")
573
+ return f"{parsed.scheme}://{parsed.netloc}"
574
+
575
+
576
+ def internal_network(network: str) -> bool:
577
+ if not shutil.which("docker") or network in {"", "host", "none"}:
578
+ return False
579
+ completed = subprocess.run(
580
+ ["docker", "network", "inspect", network, "--format", "{{.Internal}}"],
581
+ text=True,
582
+ stdout=subprocess.PIPE,
583
+ stderr=subprocess.DEVNULL,
584
+ check=False,
585
+ )
586
+ return completed.returncode == 0 and completed.stdout.strip().lower() == "true"
587
+
588
+
589
+ def build_image(image: str) -> None:
590
+ available = inspect_image(image)["available"] == "true"
591
+ if available:
592
+ return
593
+ completed = subprocess.run(["docker", "pull", image], check=False)
594
+ if completed.returncode != 0:
595
+ raise WebCaptureError(f"Could not pull web capture image: {image}")
596
+
597
+
598
+ def png_dimensions(path: Path) -> tuple[int, int]:
599
+ data = path.read_bytes()[:24]
600
+ if len(data) != 24 or data[:8] != b"\x89PNG\r\n\x1a\n":
601
+ raise WebCaptureError(f"Invalid PNG output: {path}")
602
+ return struct.unpack(">II", data[16:24])
603
+
604
+
605
+ def xml(value: Any) -> str:
606
+ return html.escape(str(value), quote=True)
607
+
608
+
609
+ def annotation_box(annotation: dict[str, Any], origin: dict[str, float], scale: float) -> dict[str, float]:
610
+ box = annotation["box"]
611
+ padding = annotation["padding"]
612
+ return {
613
+ "x": (box["x"] - origin["x"] - padding) * scale,
614
+ "y": (box["y"] - origin["y"] - padding) * scale,
615
+ "width": (box["width"] + padding * 2) * scale,
616
+ "height": (box["height"] + padding * 2) * scale,
617
+ }
618
+
619
+
620
+ def label_position(box: dict[str, float], position: str, offset: list[float], scale: float) -> tuple[float, float]:
621
+ x = box["x"] + box["width"]
622
+ y = box["y"] - 12 * scale
623
+ if "left" in position:
624
+ x = box["x"]
625
+ if "bottom" in position:
626
+ y = box["y"] + box["height"] + 26 * scale
627
+ if position == "left":
628
+ x, y = box["x"] - 16 * scale, box["y"] + box["height"] / 2
629
+ if position == "right":
630
+ x, y = box["x"] + box["width"] + 16 * scale, box["y"] + box["height"] / 2
631
+ return x + offset[0] * scale, y + offset[1] * scale
632
+
633
+
634
+ def svg_for(project: Path, record: dict[str, Any], png: Path, worker: dict[str, Any], fingerprint_value: str) -> str:
635
+ width, height = png_dimensions(png)
636
+ scale = float(worker["device_scale_factor"])
637
+ metadata = {
638
+ "schema": 1,
639
+ "source": record["source_path"],
640
+ "png_sha256": file_signature(png)["sha256"],
641
+ "fingerprint": fingerprint_value,
642
+ "path": record["recipe"]["path"],
643
+ "viewport": record["recipe"]["viewport"],
644
+ "browser_version": worker.get("browser_version", ""),
645
+ }
646
+ background = base64.b64encode(png.read_bytes()).decode("ascii")
647
+ highlights: list[str] = []
648
+ arrows: list[str] = []
649
+ labels: list[str] = []
650
+ notes: list[str] = []
651
+ for annotation in worker.get("annotations", []):
652
+ box = annotation_box(annotation, worker["origin"], scale)
653
+ if box["x"] + box["width"] <= 0 or box["y"] + box["height"] <= 0 or box["x"] >= width or box["y"] >= height:
654
+ if annotation.get("required", True):
655
+ raise WebCaptureError(f"Annotation is outside the captured area: {annotation['id']}")
656
+ continue
657
+ identifier = xml(annotation["id"])
658
+ selector = xml(annotation["selector"])
659
+ color = annotation["color"]
660
+ stroke = annotation["stroke_width"] * scale
661
+ rect = f'<rect x="{box["x"]:.2f}" y="{box["y"]:.2f}" width="{box["width"]:.2f}" height="{box["height"]:.2f}" rx="{6 * scale:.2f}" fill="none" stroke="{color}" stroke-width="{stroke:.2f}"/>'
662
+ if annotation["kind"] == "spotlight":
663
+ path = f'M0 0H{width}V{height}H0Z M{box["x"]:.2f} {box["y"]:.2f}h{box["width"]:.2f}v{box["height"]:.2f}h-{box["width"]:.2f}Z'
664
+ highlights.append(f'<g id="annotation-{identifier}" data-selector="{selector}"><path d="{path}" fill="#000000" fill-opacity="0.48" fill-rule="evenodd"/>{rect}</g>')
665
+ else:
666
+ highlights.append(f'<g id="annotation-{identifier}" data-selector="{selector}">{rect}</g>')
667
+ text = annotation.get("text", "")
668
+ if text:
669
+ lx, ly = label_position(box, annotation["position"], annotation["offset"], scale)
670
+ text_width = max(44, min(420, len(text) * 8 + 20)) * scale
671
+ text_height = 30 * scale
672
+ lx = max(4 * scale, min(lx, width - text_width - 4 * scale))
673
+ ly = max(text_height, min(ly, height - 4 * scale))
674
+ label = (
675
+ f'<g id="label-{identifier}" data-selector="{selector}">'
676
+ f'<rect x="{lx:.2f}" y="{ly - text_height:.2f}" width="{text_width:.2f}" height="{text_height:.2f}" rx="{5 * scale:.2f}" fill="{color}"/>'
677
+ f'<text x="{lx + 10 * scale:.2f}" y="{ly - 9 * scale:.2f}" fill="#ffffff" font-family="Arial, sans-serif" font-size="{14 * scale:.2f}" font-weight="600">{xml(text)}</text></g>'
678
+ )
679
+ labels.append(label)
680
+ if annotation["kind"] == "arrow":
681
+ cx = box["x"] + box["width"] / 2
682
+ cy = box["y"] + box["height"] / 2
683
+ arrows.append(f'<path id="arrow-{identifier}" d="M{lx + text_width / 2:.2f} {ly:.2f} L{cx:.2f} {cy:.2f}" fill="none" stroke="{color}" stroke-width="{stroke:.2f}" marker-end="url(#arrowhead)"/>')
684
+ if annotation["kind"] == "label" and not text:
685
+ notes.append(f'<!-- Empty label annotation: {identifier} -->')
686
+ metadata_text = xml(json.dumps(metadata, ensure_ascii=False, sort_keys=True, separators=(",", ":")))
687
+ return (
688
+ '<?xml version="1.0" encoding="UTF-8"?>\n'
689
+ f'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="{width}" height="{height}" viewBox="0 0 {width} {height}">\n'
690
+ f' <metadata id="unaltraweb-capture">{metadata_text}</metadata>\n'
691
+ ' <defs><marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto"><polygon points="0 0, 10 3.5, 0 7" fill="#b42318"/></marker></defs>\n'
692
+ f' <g id="background" inkscape:groupmode="layer" inkscape:label="Background"><image width="{width}" height="{height}" href="data:image/png;base64,{background}"/></g>\n'
693
+ f' <g id="highlights" inkscape:groupmode="layer" inkscape:label="Highlights">{"".join(highlights)}</g>\n'
694
+ f' <g id="arrows" inkscape:groupmode="layer" inkscape:label="Arrows">{"".join(arrows)}</g>\n'
695
+ f' <g id="labels" inkscape:groupmode="layer" inkscape:label="Labels">{"".join(labels)}</g>\n'
696
+ f' <g id="notes" inkscape:groupmode="layer" inkscape:label="Notes">{"".join(notes)}</g>\n'
697
+ '</svg>\n'
698
+ )
699
+
700
+
701
+ def worker_config(record: dict[str, Any], base_url: str, png_path: str, result_path: str) -> dict[str, Any]:
702
+ recipe = record["recipe"]
703
+ return {
704
+ "url": base_url + recipe["path"],
705
+ "viewport": recipe["viewport"],
706
+ "capture": recipe["capture"],
707
+ "theme": recipe["theme"],
708
+ "waits": recipe["waits"],
709
+ "annotations": recipe["annotations"],
710
+ "locale": recipe["locale"],
711
+ "png_path": png_path,
712
+ "result_path": result_path,
713
+ }
714
+
715
+
716
+ def run_worker(project: Path, image: str, config_path: Path) -> None:
717
+ network = os.environ.get("WEB_CAPTURE_DOCKER_NETWORK", "").strip()
718
+ if not internal_network(network):
719
+ raise WebCaptureError("Browser capture requires a named isolated Docker network.")
720
+ worker_values = {
721
+ "role": os.environ.get("UNALTRAWEB_WORKER_ROLE", "").strip(),
722
+ "project": os.environ.get("UNALTRAWEB_WORKER_PROJECT", "").strip(),
723
+ "token": os.environ.get("UNALTRAWEB_WORKER_TOKEN", "").strip(),
724
+ }
725
+ worker_args: list[str] = []
726
+ cidfile: Path | None = None
727
+ if any(worker_values.values()):
728
+ if worker_values["role"] != "web-capture" or not re.fullmatch(r"[0-9a-f]{16}", worker_values["project"]) or not re.fullmatch(r"[0-9a-f]{16}", worker_values["token"]):
729
+ raise WebCaptureError("Web capture worker identity is incomplete or invalid.")
730
+ runtime = project / "tmp/.unaltraweb/web-captures"
731
+ runtime.mkdir(parents=True, exist_ok=True)
732
+ cidfile = runtime / f"worker-{worker_values['token']}.cid"
733
+ cidfile.unlink(missing_ok=True)
734
+ worker_args = [
735
+ "--cidfile", str(cidfile),
736
+ "--label", "io.context.mcp-factory=unaltraweb",
737
+ "--label", f"io.context.mcp-role={worker_values['role']}",
738
+ "--label", f"io.context.mcp-project={worker_values['project']}",
739
+ "--label", f"io.context.mcp-worker-token={worker_values['token']}",
740
+ ]
741
+ command = [
742
+ "docker", "run", "--rm", *worker_args, "--user", f"{os.getuid()}:{os.getgid()}", "--network", network, "--ipc=host",
743
+ "--read-only", "--cap-drop", "ALL", "--security-opt", "no-new-privileges", "--pids-limit", "256", "--cpus", "2", "--memory", "2g",
744
+ "--tmpfs", "/tmp:rw,noexec,nosuid,size=512m", "-e", "HOME=/tmp",
745
+ "--mount", docker_bind_mount(project, "/project"), "-w", "/project", image,
746
+ "/project/" + relative(project, config_path),
747
+ ]
748
+ try:
749
+ completed = subprocess.run(command, check=False)
750
+ finally:
751
+ if cidfile is not None:
752
+ cidfile.unlink(missing_ok=True)
753
+ if completed.returncode != 0:
754
+ raise WebCaptureError(f"Browser capture failed with exit code {completed.returncode}.")
755
+
756
+
757
+ def publish(record: dict[str, Any], png_temp: Path, svg_text: str, *, confirm_overwrite: bool, owned: bool) -> None:
758
+ png = record["png"]
759
+ svg = record["svg"]
760
+ if not owned and (png.exists() or svg.exists()) and not confirm_overwrite:
761
+ raise WebCaptureError(f"Refusing to replace unmanaged capture outputs for {record['source_path']}; rerun with --confirm-overwrite after review.")
762
+ png.parent.mkdir(parents=True, exist_ok=True)
763
+ token = secrets.token_hex(6)
764
+ svg_temp = svg.with_name(f".{svg.name}.{token}.tmp")
765
+ png_stage = png.with_name(f".{png.name}.{token}.tmp")
766
+ png_backup = png.with_name(f".{png.name}.{token}.backup")
767
+ svg_backup = svg.with_name(f".{svg.name}.{token}.backup")
768
+ shutil.copy2(png_temp, png_stage)
769
+ svg_temp.write_text(svg_text, encoding="utf-8")
770
+ try:
771
+ if png.exists():
772
+ os.replace(png, png_backup)
773
+ if svg.exists():
774
+ os.replace(svg, svg_backup)
775
+ os.replace(png_stage, png)
776
+ os.replace(svg_temp, svg)
777
+ except Exception:
778
+ png.unlink(missing_ok=True)
779
+ svg.unlink(missing_ok=True)
780
+ if png_backup.exists():
781
+ os.replace(png_backup, png)
782
+ if svg_backup.exists():
783
+ os.replace(svg_backup, svg)
784
+ raise
785
+ finally:
786
+ for path in [png_stage, svg_temp, png_backup, svg_backup]:
787
+ path.unlink(missing_ok=True)
788
+
789
+
790
+ @contextlib.contextmanager
791
+ def project_lock(project: Path):
792
+ root = project / "tmp" / "web-captures"
793
+ root.mkdir(parents=True, exist_ok=True)
794
+ with (root / ".render.lock").open("w", encoding="utf-8") as stream:
795
+ fcntl.flock(stream.fileno(), fcntl.LOCK_EX)
796
+ yield
797
+
798
+
799
+ def render(project: Path, base_url: str, source: str = "", confirm_overwrite: bool = False) -> dict[str, Any]:
800
+ base_url = validate_base_url(base_url)
801
+ selected, _ = selected_records(project, source)
802
+ image = selected_image()
803
+ build_image(image)
804
+ lock = load_lock(project)
805
+ rendered = []
806
+ with project_lock(project):
807
+ for record in selected:
808
+ saved = lock["records"].get(record["source_path"], {})
809
+ current_fingerprint, dependencies, image_state = fingerprint(project, record, saved)
810
+ with tempfile.TemporaryDirectory(prefix=f"{record['source'].stem}-", dir=project / "tmp" / "web-captures") as temporary_raw:
811
+ temporary = Path(temporary_raw)
812
+ png_temp = temporary / "capture.png"
813
+ result_temp = temporary / "result.json"
814
+ config_temp = temporary / "config.json"
815
+ config_temp.write_text(json_dump(worker_config(record, base_url, "/project/" + relative(project, png_temp), "/project/" + relative(project, result_temp))), encoding="utf-8")
816
+ run_worker(project, image, config_temp)
817
+ if not png_temp.is_file() or not result_temp.is_file():
818
+ raise WebCaptureError(f"Browser worker did not produce capture outputs for {record['source_path']}.")
819
+ worker = json.loads(result_temp.read_text(encoding="utf-8"))
820
+ svg_text = svg_for(project, record, png_temp, worker, current_fingerprint)
821
+ publish(record, png_temp, svg_text, confirm_overwrite=confirm_overwrite, owned=bool(saved))
822
+ identity = inspect_image(image)
823
+ lock["records"][record["source_path"]] = {
824
+ "fingerprint": current_fingerprint,
825
+ "dependencies": dependencies,
826
+ "image": image,
827
+ "image_identity": identity,
828
+ "png": {"path": relative(project, record["png"]), **file_signature(record["png"])},
829
+ "svg": {"path": relative(project, record["svg"]), **file_signature(record["svg"])},
830
+ "captured_at": utc_now(),
831
+ "final_path": urlsplit(worker.get("final_url", "")).path,
832
+ "browser_version": worker.get("browser_version", ""),
833
+ }
834
+ write_lock(project, lock)
835
+ rendered.append({"source": record["source_path"], "png": relative(project, record["png"]), "svg": relative(project, record["svg"]), "effective_svg": relative(project, record["edited"] if record["edited"].is_file() else record["svg"]), "annotations": len(worker.get("annotations", []))})
836
+ return {"project": str(project), "rendered": rendered, "rendered_count": len(rendered), "ok": True}
837
+
838
+
839
+ def parser() -> argparse.ArgumentParser:
840
+ root = argparse.ArgumentParser(prog="unaltraweb-web-captures")
841
+ root.add_argument("command", choices=["status", "check", "render"])
842
+ root.add_argument("--project", default=".")
843
+ root.add_argument("--source", default="")
844
+ root.add_argument("--base-url", default="")
845
+ root.add_argument("--confirm-overwrite", action="store_true")
846
+ return root
847
+
848
+
849
+ def main(argv: list[str] | None = None) -> int:
850
+ args = parser().parse_args(argv)
851
+ project = Path(args.project).expanduser().resolve()
852
+ try:
853
+ if args.command in {"status", "check"}:
854
+ result = status(project, source=args.source)
855
+ else:
856
+ if not args.base_url:
857
+ raise WebCaptureError("render requires --base-url for a trusted running preview.")
858
+ result = render(project, args.base_url, source=args.source, confirm_overwrite=args.confirm_overwrite)
859
+ print(json_dump(result), end="")
860
+ return 0 if args.command == "status" or result.get("ok") else 1
861
+ except WebCaptureError as exc:
862
+ print(json_dump({"project": str(project), "ok": False, "error": str(exc)}), end="")
863
+ return 2
864
+
865
+
866
+ if __name__ == "__main__":
867
+ raise SystemExit(main(sys.argv[1:]))