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,650 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import re
9
+ import stat
10
+ import tarfile
11
+ import unicodedata
12
+ import zlib
13
+ from pathlib import Path, PurePosixPath
14
+ from typing import Any
15
+
16
+
17
+ SELECTOR_RE = re.compile(r"v[0-9]{4}\.(?:0[1-9]|1[0-2])(?:\.[1-9][0-9]*)?\Z")
18
+ SHA256_RE = re.compile(r"[0-9a-f]{64}\Z")
19
+ IMAGE_RE = re.compile(r"ghcr\.io/dosquartsdedocs/unaltraweb-manual-pdf@sha256:[0-9a-f]{64}\Z")
20
+ SITE_IMAGE_RE = re.compile(r"ghcr\.io/dosquartsdedocs/unaltraweb-mcp@sha256:[0-9a-f]{64}\Z")
21
+ FINGERPRINT_SCHEMA = "unaltraweb-tree-v1"
22
+ MAX_JSON_BYTES = 8 * 1024 * 1024
23
+ MAX_TAR_MEMBERS = 200_000
24
+ MAX_SITE_ARCHIVE_BYTES = 2 * 1024 * 1024 * 1024
25
+ MAX_TAR_CONTENT_BYTES = 2 * 1024 * 1024 * 1024
26
+ MAX_TAR_STREAM_BYTES = 4 * 1024 * 1024 * 1024
27
+
28
+
29
+ class VerificationError(RuntimeError):
30
+ pass
31
+
32
+
33
+ def canonical_json(value: Any) -> bytes:
34
+ return (json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode("utf-8")
35
+
36
+
37
+ def strict_json(content: bytes, *, label: str) -> Any:
38
+ if len(content) > MAX_JSON_BYTES:
39
+ raise VerificationError(f"{label} exceeds the JSON size limit.")
40
+
41
+ def unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
42
+ result: dict[str, Any] = {}
43
+ for key, value in pairs:
44
+ if key in result:
45
+ raise VerificationError(f"{label} contains duplicate key {key!r}.")
46
+ result[key] = value
47
+ return result
48
+
49
+ def reject_constant(value: str) -> None:
50
+ raise VerificationError(f"{label} contains non-finite number {value}.")
51
+
52
+ try:
53
+ value = json.loads(content.decode("utf-8"), object_pairs_hook=unique_object, parse_constant=reject_constant)
54
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
55
+ raise VerificationError(f"{label} is not strict UTF-8 JSON: {exc}") from exc
56
+ if canonical_json(value) != content:
57
+ raise VerificationError(f"{label} is not canonical JSON.")
58
+ return value
59
+
60
+
61
+ def name_key(value: str) -> str:
62
+ return unicodedata.normalize("NFC", value).casefold()
63
+
64
+
65
+ def safe_relative(value: Any, *, label: str) -> PurePosixPath:
66
+ if not isinstance(value, str) or not value or value != value.strip() or value.startswith("/") or "\\" in value:
67
+ raise VerificationError(f"{label} is not a safe relative path: {value!r}")
68
+ if any(ord(character) < 32 or ord(character) == 127 for character in value):
69
+ raise VerificationError(f"{label} contains control characters.")
70
+ parts = value.split("/")
71
+ if any(part in {"", ".", ".."} or part != part.strip() for part in parts):
72
+ raise VerificationError(f"{label} contains an empty or traversal segment: {value!r}")
73
+ return PurePosixPath(*parts)
74
+
75
+
76
+ def file_record(path: Path, *, label: str) -> dict[str, Any]:
77
+ descriptor: int | None = None
78
+ try:
79
+ descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0))
80
+ before = os.fstat(descriptor)
81
+ if not stat.S_ISREG(before.st_mode):
82
+ raise VerificationError(f"{label} is not a regular file.")
83
+ digest = hashlib.sha256()
84
+ size = 0
85
+ while True:
86
+ chunk = os.read(descriptor, 1024 * 1024)
87
+ if not chunk:
88
+ break
89
+ digest.update(chunk)
90
+ size += len(chunk)
91
+ after = os.fstat(descriptor)
92
+ identity_before = (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns, before.st_ctime_ns)
93
+ identity_after = (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns, after.st_ctime_ns)
94
+ if identity_before != identity_after or size != after.st_size:
95
+ raise VerificationError(f"{label} changed while it was read.")
96
+ return {"sha256": digest.hexdigest(), "size": size}
97
+ except OSError as exc:
98
+ raise VerificationError(f"Cannot read {label}: {exc}") from exc
99
+ finally:
100
+ if descriptor is not None:
101
+ os.close(descriptor)
102
+
103
+
104
+ def regular_content(path: Path, *, label: str, max_bytes: int = MAX_JSON_BYTES) -> tuple[bytes, dict[str, Any]]:
105
+ descriptor: int | None = None
106
+ try:
107
+ descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0))
108
+ before = os.fstat(descriptor)
109
+ if not stat.S_ISREG(before.st_mode) or before.st_size > max_bytes:
110
+ raise VerificationError(f"{label} is not a bounded regular file.")
111
+ content = bytearray()
112
+ digest = hashlib.sha256()
113
+ while len(content) <= max_bytes:
114
+ chunk = os.read(descriptor, min(65536, max_bytes + 1 - len(content)))
115
+ if not chunk:
116
+ break
117
+ content.extend(chunk)
118
+ digest.update(chunk)
119
+ after = os.fstat(descriptor)
120
+ identity_before = (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns, before.st_ctime_ns)
121
+ identity_after = (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns, after.st_ctime_ns)
122
+ if identity_before != identity_after or len(content) != after.st_size:
123
+ raise VerificationError(f"{label} changed while it was read.")
124
+ if len(content) > max_bytes:
125
+ raise VerificationError(f"{label} exceeds the size limit.")
126
+ return bytes(content), {"sha256": digest.hexdigest(), "size": len(content)}
127
+ except OSError as exc:
128
+ raise VerificationError(f"Cannot read {label}: {exc}") from exc
129
+ finally:
130
+ if descriptor is not None:
131
+ os.close(descriptor)
132
+
133
+
134
+ def regular_asset_inventory(assets: Path) -> dict[str, Path]:
135
+ try:
136
+ metadata = assets.lstat()
137
+ except OSError as exc:
138
+ raise VerificationError(f"Release asset directory is unavailable: {exc}") from exc
139
+ if not stat.S_ISDIR(metadata.st_mode):
140
+ raise VerificationError("Release asset root must be a real directory.")
141
+ inventory: dict[str, Path] = {}
142
+ keys: set[str] = set()
143
+ for entry in os.scandir(assets):
144
+ entry_metadata = entry.stat(follow_symlinks=False)
145
+ if not stat.S_ISREG(entry_metadata.st_mode):
146
+ raise VerificationError(f"Release asset must be a regular file: {entry.name}")
147
+ safe_relative(entry.name, label="release asset name")
148
+ key = name_key(entry.name)
149
+ if key in keys:
150
+ raise VerificationError(f"Release assets contain colliding names: {entry.name}")
151
+ keys.add(key)
152
+ inventory[entry.name] = Path(entry.path)
153
+ return inventory
154
+
155
+
156
+ def _compare_regular_files(reference: Path, received: Path, *, label: str) -> None:
157
+ descriptors: list[int] = []
158
+ try:
159
+ for path in (reference, received):
160
+ descriptors.append(
161
+ os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0))
162
+ )
163
+ reference_before, received_before = (os.fstat(descriptor) for descriptor in descriptors)
164
+ if not stat.S_ISREG(reference_before.st_mode) or not stat.S_ISREG(received_before.st_mode):
165
+ raise VerificationError(f"{label} must compare two regular files.")
166
+ if (reference_before.st_dev, reference_before.st_ino) == (received_before.st_dev, received_before.st_ino):
167
+ raise VerificationError(f"{label} must compare independently transferred files.")
168
+
169
+ identical = reference_before.st_size == received_before.st_size
170
+ while identical:
171
+ reference_chunk = os.read(descriptors[0], 1024 * 1024)
172
+ received_chunk = os.read(descriptors[1], 1024 * 1024)
173
+ if reference_chunk != received_chunk:
174
+ identical = False
175
+ break
176
+ if not reference_chunk:
177
+ break
178
+
179
+ reference_after, received_after = (os.fstat(descriptor) for descriptor in descriptors)
180
+ for name, before, after in [
181
+ ("reference", reference_before, reference_after),
182
+ ("received", received_before, received_after),
183
+ ]:
184
+ before_identity = (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns, before.st_ctime_ns)
185
+ after_identity = (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns, after.st_ctime_ns)
186
+ if before_identity != after_identity:
187
+ raise VerificationError(f"{label} {name} changed while it was compared.")
188
+ if not identical:
189
+ raise VerificationError(f"{label} differs from the locally verified release asset.")
190
+ except OSError as exc:
191
+ raise VerificationError(f"Cannot compare {label}: {exc}") from exc
192
+ finally:
193
+ for descriptor in descriptors:
194
+ os.close(descriptor)
195
+
196
+
197
+ def compare_asset_directories(reference: Path, received: Path) -> None:
198
+ try:
199
+ reference_metadata = reference.lstat()
200
+ received_metadata = received.lstat()
201
+ except OSError as exc:
202
+ raise VerificationError(f"Cannot inspect release asset roots for exact comparison: {exc}") from exc
203
+ if (reference_metadata.st_dev, reference_metadata.st_ino) == (received_metadata.st_dev, received_metadata.st_ino):
204
+ raise VerificationError("Reference and received release asset roots must be independent directories.")
205
+
206
+ reference_assets = regular_asset_inventory(reference)
207
+ received_assets = regular_asset_inventory(received)
208
+ if set(reference_assets) != set(received_assets):
209
+ raise VerificationError("Received release assets do not exactly match the local asset names.")
210
+ for name in sorted(reference_assets):
211
+ _compare_regular_files(
212
+ reference_assets[name],
213
+ received_assets[name],
214
+ label=f"received release asset {name}",
215
+ )
216
+
217
+
218
+ def _validate_single_gzip_member(
219
+ descriptor: int,
220
+ *,
221
+ logical_tar_end: int | None = None,
222
+ expected_metadata: os.stat_result | None = None,
223
+ ) -> os.stat_result:
224
+ before = os.fstat(descriptor)
225
+ if not stat.S_ISREG(before.st_mode):
226
+ raise VerificationError("Site archive is not a regular file.")
227
+ if before.st_size > MAX_SITE_ARCHIVE_BYTES:
228
+ raise VerificationError("Site archive exceeds the compressed size limit.")
229
+ before_identity = (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns, before.st_ctime_ns)
230
+ if expected_metadata is not None:
231
+ expected_identity = (
232
+ expected_metadata.st_dev,
233
+ expected_metadata.st_ino,
234
+ expected_metadata.st_size,
235
+ expected_metadata.st_mtime_ns,
236
+ expected_metadata.st_ctime_ns,
237
+ )
238
+ if before_identity != expected_identity:
239
+ raise VerificationError("Site archive changed before its tar padding was validated.")
240
+ if logical_tar_end is not None and (logical_tar_end < 0 or logical_tar_end % tarfile.BLOCKSIZE):
241
+ raise VerificationError("Site archive logical tar end is not block aligned.")
242
+
243
+ os.lseek(descriptor, 0, os.SEEK_SET)
244
+ decoder = zlib.decompressobj(16 + zlib.MAX_WBITS)
245
+ decoded_size = 0
246
+ try:
247
+ while True:
248
+ chunk = os.read(descriptor, 1024 * 1024)
249
+ if not chunk:
250
+ break
251
+ if decoder.eof:
252
+ raise VerificationError("Site archive has trailing bytes or a concatenated gzip member.")
253
+ pending = chunk
254
+ while pending:
255
+ output = decoder.decompress(
256
+ pending,
257
+ min(1024 * 1024, MAX_TAR_STREAM_BYTES - decoded_size + 1),
258
+ )
259
+ output_start = decoded_size
260
+ decoded_size += len(output)
261
+ if decoded_size > MAX_TAR_STREAM_BYTES:
262
+ raise VerificationError("Site archive exceeds the decompressed stream size limit.")
263
+ if logical_tar_end is not None and decoded_size > logical_tar_end:
264
+ tail_start = max(0, logical_tar_end - output_start)
265
+ if output[tail_start:].strip(b"\0"):
266
+ raise VerificationError("Site archive contains nonzero data after its logical tar end.")
267
+ if decoder.eof:
268
+ if decoder.unused_data:
269
+ raise VerificationError("Site archive has trailing bytes or a concatenated gzip member.")
270
+ break
271
+ pending = decoder.unconsumed_tail
272
+ if not decoder.eof:
273
+ raise VerificationError("Site archive contains an incomplete gzip stream.")
274
+ except zlib.error as exc:
275
+ raise VerificationError(f"Site archive is not a valid gzip stream: {exc}") from exc
276
+
277
+ if logical_tar_end is not None:
278
+ if decoded_size % tarfile.BLOCKSIZE:
279
+ raise VerificationError("Site archive decompressed stream is not block aligned.")
280
+ if decoded_size - logical_tar_end < 2 * tarfile.BLOCKSIZE:
281
+ raise VerificationError("Site archive omits the canonical two-block tar end marker.")
282
+
283
+ after = os.fstat(descriptor)
284
+ after_identity = (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns, after.st_ctime_ns)
285
+ if before_identity != after_identity:
286
+ raise VerificationError("Site archive changed while its gzip stream was validated.")
287
+ os.lseek(descriptor, 0, os.SEEK_SET)
288
+ return before
289
+
290
+
291
+ def tar_tree_record(archive_path: Path) -> tuple[dict[str, Any], dict[str, dict[str, Any]]]:
292
+ directories: dict[str, dict[str, Any]] = {}
293
+ files: dict[str, dict[str, Any]] = {}
294
+ keys: set[str] = set()
295
+ root_members = 0
296
+ member_count = 0
297
+ content_size = 0
298
+ logical_tar_end: int | None = None
299
+ descriptor: int | None = None
300
+ try:
301
+ descriptor = os.open(
302
+ archive_path,
303
+ os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0),
304
+ )
305
+ archive_metadata = _validate_single_gzip_member(descriptor)
306
+ with os.fdopen(os.dup(descriptor), "rb") as archive_file:
307
+ with tarfile.open(fileobj=archive_file, mode="r:gz") as archive:
308
+ for member in archive:
309
+ member_count += 1
310
+ if member_count > MAX_TAR_MEMBERS:
311
+ raise VerificationError("Site archive contains too many members.")
312
+ if member.uid != 0 or member.gid != 0 or member.mtime != 0:
313
+ raise VerificationError("Site archive contains non-canonical owner or timestamp metadata.")
314
+ raw = member.name
315
+ while raw.startswith("./"):
316
+ raw = raw[2:]
317
+ raw = raw.rstrip("/")
318
+ if raw in {"", "."}:
319
+ root_members += 1
320
+ if not member.isdir() or member.mode & 0o7777 != 0o755:
321
+ raise VerificationError("Site archive root member is not one canonical mode-0755 directory.")
322
+ continue
323
+ relative = safe_relative(raw, label="site archive member").as_posix()
324
+ key = name_key(relative)
325
+ if key in keys:
326
+ raise VerificationError(f"Site archive contains duplicate or colliding member: {relative}")
327
+ keys.add(key)
328
+ if member.isdir():
329
+ if member.mode & 0o7777 != 0o755:
330
+ raise VerificationError(f"Site archive directory does not use canonical mode 0755: {relative}")
331
+ directories[relative] = {"path": relative, "mode": member.mode & 0o7777}
332
+ continue
333
+ if not member.isfile():
334
+ raise VerificationError(f"Site archive contains a link or special member: {relative}")
335
+ if member.mode & 0o7777 != 0o644:
336
+ raise VerificationError(f"Site archive file does not use canonical mode 0644: {relative}")
337
+ if member.size > MAX_TAR_CONTENT_BYTES - content_size:
338
+ raise VerificationError("Site archive exceeds the file content size limit.")
339
+ content_size += member.size
340
+ source = archive.extractfile(member)
341
+ if source is None:
342
+ raise VerificationError(f"Cannot read site archive member: {relative}")
343
+ digest = hashlib.sha256()
344
+ size = 0
345
+ while True:
346
+ chunk = source.read(1024 * 1024)
347
+ if not chunk:
348
+ break
349
+ digest.update(chunk)
350
+ size += len(chunk)
351
+ if size != member.size:
352
+ raise VerificationError(f"Site archive member has an inconsistent size: {relative}")
353
+ files[relative] = {
354
+ "path": relative,
355
+ "sha256": digest.hexdigest(),
356
+ "size": size,
357
+ "mode": member.mode & 0o7777,
358
+ }
359
+ logical_tar_end = archive.offset
360
+ if logical_tar_end is None:
361
+ raise VerificationError("Site archive has no logical tar end.")
362
+ _validate_single_gzip_member(
363
+ descriptor,
364
+ logical_tar_end=logical_tar_end,
365
+ expected_metadata=archive_metadata,
366
+ )
367
+ final_metadata = os.fstat(descriptor)
368
+ archive_identity = (
369
+ archive_metadata.st_dev,
370
+ archive_metadata.st_ino,
371
+ archive_metadata.st_size,
372
+ archive_metadata.st_mtime_ns,
373
+ archive_metadata.st_ctime_ns,
374
+ )
375
+ final_identity = (
376
+ final_metadata.st_dev,
377
+ final_metadata.st_ino,
378
+ final_metadata.st_size,
379
+ final_metadata.st_mtime_ns,
380
+ final_metadata.st_ctime_ns,
381
+ )
382
+ if archive_identity != final_identity:
383
+ raise VerificationError("Site archive changed while it was inspected.")
384
+ finally:
385
+ if descriptor is not None:
386
+ os.close(descriptor)
387
+
388
+ if root_members != 1:
389
+ raise VerificationError("Site archive must contain exactly one canonical root directory member.")
390
+
391
+ children: dict[str, set[str]] = {"": set()}
392
+ for relative in [*directories, *files]:
393
+ path = PurePosixPath(relative)
394
+ parent = "" if path.parent == PurePosixPath(".") else path.parent.as_posix()
395
+ if parent and parent not in directories:
396
+ raise VerificationError(f"Site archive omits parent directory for {relative}.")
397
+ children.setdefault(parent, set()).add(path.name)
398
+ children.setdefault(relative, set())
399
+
400
+ ordered_directories: list[dict[str, Any]] = []
401
+ ordered_files: list[dict[str, Any]] = []
402
+
403
+ def walk(parent: str) -> None:
404
+ for child in sorted(children.get(parent, set())):
405
+ relative = f"{parent}/{child}" if parent else child
406
+ if relative in directories:
407
+ ordered_directories.append(directories[relative])
408
+ walk(relative)
409
+ elif relative in files:
410
+ ordered_files.append(files[relative])
411
+ else:
412
+ raise VerificationError(f"Site archive inventory is inconsistent at {relative}.")
413
+
414
+ walk("")
415
+ if len(ordered_directories) != len(directories) or len(ordered_files) != len(files):
416
+ raise VerificationError("Site archive contains unreachable inventory entries.")
417
+ payload = {
418
+ "schema": FINGERPRINT_SCHEMA,
419
+ "directories": ordered_directories,
420
+ "files": ordered_files,
421
+ }
422
+ tree = {
423
+ "bytes": sum(record["size"] for record in ordered_files),
424
+ "file_count": len(ordered_files),
425
+ "path": "site",
426
+ "sha256": hashlib.sha256(b"unaltraweb-tree-v1\0" + canonical_json(payload)).hexdigest(),
427
+ }
428
+ return tree, files
429
+
430
+
431
+ def verify_sums(path: Path, assets: dict[str, Path], expected_names: set[str]) -> None:
432
+ content, _ = regular_content(path, label="SHA256SUMS")
433
+ lines = content.splitlines(keepends=True)
434
+ if not lines or any(not line.endswith(b"\n") for line in lines):
435
+ raise VerificationError("SHA256SUMS must be non-empty and newline terminated.")
436
+ received: dict[str, str] = {}
437
+ for line in lines:
438
+ value = line[:-1].decode("utf-8")
439
+ if " " not in value:
440
+ raise VerificationError("SHA256SUMS contains a malformed line.")
441
+ digest, raw_name = value.split(" ", 1)
442
+ if raw_name.startswith("./"):
443
+ raw_name = raw_name[2:]
444
+ name = safe_relative(raw_name, label="SHA256SUMS asset").as_posix()
445
+ if "/" in name or not SHA256_RE.fullmatch(digest) or name in received:
446
+ raise VerificationError("SHA256SUMS contains an unsafe, duplicate, or invalid entry.")
447
+ received[name] = digest
448
+ if set(received) != expected_names:
449
+ raise VerificationError("SHA256SUMS does not exactly inventory the authorized release assets.")
450
+ for name, digest in received.items():
451
+ if file_record(assets[name], label=f"release asset {name}")["sha256"] != digest:
452
+ raise VerificationError(f"Release asset checksum mismatch: {name}")
453
+
454
+
455
+ def verify(args: argparse.Namespace) -> dict[str, Any]:
456
+ if not SELECTOR_RE.fullmatch(args.selector):
457
+ raise VerificationError("Invalid stable selector.")
458
+ for label, value in [
459
+ ("reviewed SHA", args.reviewed_sha),
460
+ ("core SHA", args.core_sha),
461
+ ]:
462
+ if not re.fullmatch(r"[0-9a-f]{40}", value):
463
+ raise VerificationError(f"Invalid {label}.")
464
+ if not SHA256_RE.fullmatch(args.candidate_manifest_sha256):
465
+ raise VerificationError("Invalid candidate manifest SHA-256.")
466
+ if not IMAGE_RE.fullmatch(args.manual_pdf_image):
467
+ raise VerificationError("Invalid manual PDF image digest reference.")
468
+ if not SITE_IMAGE_RE.fullmatch(args.site_build_image):
469
+ raise VerificationError("Invalid stable site-build image digest reference.")
470
+ if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", args.python_version) or not re.fullmatch(
471
+ r"[0-9]+\.[0-9]+\.[0-9]+", args.ruby_version
472
+ ):
473
+ raise VerificationError("Stable runtime versions must use exact three-part versions.")
474
+ if args.vegavisuals_sha and not re.fullmatch(r"[0-9a-f]{40}", args.vegavisuals_sha):
475
+ raise VerificationError("Invalid vegavisuals SHA.")
476
+
477
+ assets_root = args.assets.absolute()
478
+ assets = regular_asset_inventory(assets_root)
479
+ manifest_path = assets.get("release-manifest.json")
480
+ publication_path = assets.get("publication.json")
481
+ if manifest_path is None or publication_path is None:
482
+ raise VerificationError("Release assets omit required publication metadata.")
483
+
484
+ manifest_content, manifest_record = regular_content(manifest_path, label="release-manifest.json")
485
+ if manifest_record["sha256"] != args.candidate_manifest_sha256:
486
+ raise VerificationError("release-manifest.json does not match the locally reviewed digest.")
487
+ manifest = strict_json(manifest_content, label="release-manifest.json")
488
+ expected_manifest_keys = {"channel", "fingerprint_schema", "pdfs", "schema_version", "selector", "site", "source"}
489
+ if not isinstance(manifest, dict) or set(manifest) != expected_manifest_keys:
490
+ raise VerificationError("release-manifest.json has an unexpected schema.")
491
+ if (
492
+ manifest["channel"] != "stable"
493
+ or manifest["fingerprint_schema"] != FINGERPRINT_SCHEMA
494
+ or manifest["schema_version"] != 2
495
+ or manifest["selector"] != args.selector
496
+ ):
497
+ raise VerificationError("release-manifest.json does not identify the authorized stable release.")
498
+ source = manifest.get("source")
499
+ if (
500
+ not isinstance(source, dict)
501
+ or set(source) != {"bytes", "commit", "file_count", "sha256", "site_build_image", "source_date_epoch"}
502
+ or type(source.get("bytes")) is not int
503
+ or type(source.get("file_count")) is not int
504
+ or source.get("commit") != args.reviewed_sha
505
+ or source.get("site_build_image") != args.site_build_image
506
+ or not SHA256_RE.fullmatch(str(source.get("sha256") or ""))
507
+ or not re.fullmatch(r"[0-9]+", str(source.get("source_date_epoch") or ""))
508
+ ):
509
+ raise VerificationError("release-manifest.json has an invalid source fingerprint.")
510
+
511
+ publication_content, _ = regular_content(publication_path, label="publication.json")
512
+ publication = strict_json(publication_content, label="publication.json")
513
+ if not isinstance(publication, dict) or not re.fullmatch(
514
+ r"[0-9]+\.[0-9]+\.[0-9]+", str(publication.get("site_build_python_version") or "")
515
+ ):
516
+ raise VerificationError("publication.json does not record a valid site-build Python version.")
517
+ expected_publication = {
518
+ "candidate_manifest_sha256": args.candidate_manifest_sha256,
519
+ "channel": "stable",
520
+ "core_sha": args.core_sha,
521
+ "manual_pdf_image": args.manual_pdf_image,
522
+ "python_version": args.python_version,
523
+ "repository": args.repository,
524
+ "ruby_version": args.ruby_version,
525
+ "schema_version": 2,
526
+ "selector": args.selector,
527
+ "site_build_image": args.site_build_image,
528
+ "site_build_python_version": publication["site_build_python_version"],
529
+ "source_date_epoch": source["source_date_epoch"],
530
+ "source_sha": args.reviewed_sha,
531
+ "vegavisuals_sha": args.vegavisuals_sha,
532
+ }
533
+ if publication != expected_publication:
534
+ raise VerificationError("publication.json does not match the authorized release request.")
535
+
536
+ pdfs = manifest["pdfs"]
537
+ if not isinstance(pdfs, list) or not pdfs:
538
+ raise VerificationError("release-manifest.json has no PDF inventory.")
539
+ expected_asset_names = {
540
+ "SHA256SUMS",
541
+ "publication.json",
542
+ "release-manifest.json",
543
+ f"manual-site-{args.selector}.tar.gz",
544
+ }
545
+ pdf_asset_records: dict[str, dict[str, Any]] = {}
546
+ expected_pdf_keys = {
547
+ "cover_path",
548
+ "cover_sha256",
549
+ "cover_site_path",
550
+ "cover_size",
551
+ "draft",
552
+ "language",
553
+ "path",
554
+ "published_path",
555
+ "sha256",
556
+ "site_path",
557
+ "size",
558
+ }
559
+ for record in pdfs:
560
+ if not isinstance(record, dict) or set(record) != expected_pdf_keys or record.get("draft") is not False:
561
+ raise VerificationError("Stable PDF inventory contains an invalid or draft record.")
562
+ pdf_path = safe_relative(record.get("path"), label="standalone PDF path")
563
+ published_path = safe_relative(record.get("published_path"), label="published PDF path")
564
+ site_path = safe_relative(record.get("site_path"), label="site PDF path")
565
+ cover_path = safe_relative(record.get("cover_path"), label="published cover path")
566
+ cover_site_path = safe_relative(record.get("cover_site_path"), label="site cover path")
567
+ if len(pdf_path.parts) != 2 or pdf_path.parts[0] != "pdf" or pdf_path.suffix.lower() != ".pdf":
568
+ raise VerificationError("Standalone PDF path is misplaced or has the wrong suffix.")
569
+ if site_path.parts != ("site", *published_path.parts) or cover_site_path.parts != ("site", *cover_path.parts):
570
+ raise VerificationError("PDF or cover site path does not match its published path.")
571
+ if pdf_path.name != published_path.name:
572
+ raise VerificationError("Standalone and published PDF names differ.")
573
+ if not SHA256_RE.fullmatch(str(record.get("sha256") or "")) or not SHA256_RE.fullmatch(str(record.get("cover_sha256") or "")):
574
+ raise VerificationError("PDF inventory contains an invalid digest.")
575
+ if type(record.get("size")) is not int or type(record.get("cover_size")) is not int:
576
+ raise VerificationError("PDF inventory contains an invalid size.")
577
+ key = name_key(pdf_path.name)
578
+ if key in {name_key(name) for name in expected_asset_names} or key in pdf_asset_records:
579
+ raise VerificationError("PDF asset names collide.")
580
+ expected_asset_names.add(pdf_path.name)
581
+ pdf_asset_records[key] = record
582
+
583
+ if set(assets) != expected_asset_names:
584
+ raise VerificationError("Release artifact contains missing or unauthorized files.")
585
+
586
+ site_archive = assets[f"manual-site-{args.selector}.tar.gz"]
587
+ site_tree, site_files = tar_tree_record(site_archive)
588
+ if manifest.get("site") != site_tree:
589
+ raise VerificationError("Site archive fingerprint does not match release-manifest.json.")
590
+ for record in pdfs:
591
+ pdf_name = PurePosixPath(record["path"]).name
592
+ standalone = file_record(assets[pdf_name], label=f"standalone PDF {pdf_name}")
593
+ if standalone != {"sha256": record["sha256"], "size": record["size"]}:
594
+ raise VerificationError(f"Standalone PDF does not match release-manifest.json: {pdf_name}")
595
+ site_pdf = PurePosixPath(record["site_path"]).relative_to("site").as_posix()
596
+ site_cover = PurePosixPath(record["cover_site_path"]).relative_to("site").as_posix()
597
+ site_pdf_record = site_files.get(site_pdf)
598
+ if site_pdf_record is None or (site_pdf_record["sha256"], site_pdf_record["size"]) != (
599
+ record["sha256"],
600
+ record["size"],
601
+ ):
602
+ raise VerificationError(f"Site PDF does not match release-manifest.json: {site_pdf}")
603
+ cover = site_files.get(site_cover)
604
+ if cover is None or (cover["sha256"], cover["size"]) != (record["cover_sha256"], record["cover_size"]):
605
+ raise VerificationError(f"Site cover does not match release-manifest.json: {site_cover}")
606
+
607
+ verify_sums(assets["SHA256SUMS"], assets, expected_asset_names - {"SHA256SUMS"})
608
+ return {
609
+ "asset_count": len(expected_asset_names),
610
+ "candidate_manifest_sha256": args.candidate_manifest_sha256,
611
+ "ok": True,
612
+ "selector": args.selector,
613
+ "site_fingerprint": site_tree["sha256"],
614
+ }
615
+
616
+
617
+ def parser() -> argparse.ArgumentParser:
618
+ value = argparse.ArgumentParser(description="Verify stable manual release assets against reviewed evidence.")
619
+ value.add_argument("--assets", type=Path, required=True)
620
+ value.add_argument("--selector", required=True)
621
+ value.add_argument("--reviewed-sha", required=True)
622
+ value.add_argument("--core-sha", required=True)
623
+ value.add_argument("--manual-pdf-image", required=True)
624
+ value.add_argument("--python-version", required=True)
625
+ value.add_argument("--ruby-version", required=True)
626
+ value.add_argument("--site-build-image", required=True)
627
+ value.add_argument("--vegavisuals-sha", default="")
628
+ value.add_argument("--repository", required=True)
629
+ value.add_argument("--candidate-manifest-sha256", required=True)
630
+ value.add_argument("--reference-assets", type=Path)
631
+ return value
632
+
633
+
634
+ def main() -> int:
635
+ args = parser().parse_args()
636
+ try:
637
+ if args.reference_assets is not None:
638
+ compare_asset_directories(args.reference_assets.absolute(), args.assets.absolute())
639
+ result = verify(args)
640
+ if args.reference_assets is not None:
641
+ result["reference_assets_match"] = True
642
+ except (OSError, VerificationError, tarfile.TarError) as exc:
643
+ print(json.dumps({"error": str(exc), "ok": False}, sort_keys=True))
644
+ return 1
645
+ print(json.dumps(result, sort_keys=True))
646
+ return 0
647
+
648
+
649
+ if __name__ == "__main__":
650
+ raise SystemExit(main())