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,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Unaltraweb
6
+ module ReproducibleBuildTime
7
+ EPOCH = /\A[0-9]+\z/
8
+ module_function
9
+
10
+ def build_time
11
+ raw = ENV["SOURCE_DATE_EPOCH"]
12
+ return if raw.nil?
13
+
14
+ unless EPOCH.match?(raw)
15
+ raise Jekyll::Errors::FatalException,
16
+ "SOURCE_DATE_EPOCH must be a non-negative base-10 integer"
17
+ end
18
+
19
+ time = Time.at(Integer(raw, 10)).utc
20
+ time
21
+ rescue ArgumentError, RangeError => error
22
+ raise Jekyll::Errors::FatalException,
23
+ "Invalid SOURCE_DATE_EPOCH: #{error.message}"
24
+ end
25
+
26
+ def apply(site)
27
+ time = build_time
28
+ return unless time
29
+
30
+ site.config["time"] = time.iso8601
31
+ site.time = time
32
+ end
33
+
34
+ def normalize_static_files(site)
35
+ time = build_time
36
+ return unless time
37
+
38
+ site.static_files.each do |file|
39
+ file.instance_variable_set(:@modified_time, time)
40
+ end
41
+ end
42
+ end
43
+ end
44
+
45
+ Jekyll::Hooks.register :site, :after_init do |site|
46
+ Unaltraweb::ReproducibleBuildTime.apply(site)
47
+ end
48
+
49
+ Jekyll::Hooks.register :site, :after_reset do |site|
50
+ Unaltraweb::ReproducibleBuildTime.apply(site)
51
+ end
52
+
53
+ Jekyll::Hooks.register :site, :post_read do |site|
54
+ Unaltraweb::ReproducibleBuildTime.normalize_static_files(site)
55
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+
6
+ module Jekyll
7
+ module UnaltrawebSearchData
8
+ module_function
9
+
10
+ def write(site)
11
+ return unless site.config["search_enabled"]
12
+
13
+ target = site.in_dest_dir("assets/js/search-data.js")
14
+ FileUtils.mkdir_p(File.dirname(target))
15
+ File.write(target, script(entries(site)))
16
+ end
17
+
18
+ def entries(site)
19
+ pages = site.pages.select { |page| page.data["nav"] && !page.data["autogen"] }
20
+ collections = site.collections.respond_to?(:values) ? site.collections.values : site.collections
21
+ docs = collections.reject { |collection| collection.label == "posts" }.flat_map(&:docs)
22
+
23
+ (pages + site.posts.docs + docs).filter_map do |item|
24
+ title = item.data["title"].to_s.strip
25
+ next if title.empty?
26
+
27
+ {
28
+ id: "search-#{Jekyll::Utils.slugify(title)}",
29
+ title: title,
30
+ description: item.data["description"].to_s.gsub(/\s+/, " ").strip,
31
+ section: search_section(item),
32
+ url: relative_url(site, item.url),
33
+ }
34
+ end
35
+ end
36
+
37
+ def search_section(item)
38
+ return "Navigation" unless item.respond_to?(:collection) && item.collection
39
+
40
+ collection = item.collection
41
+ label = collection.respond_to?(:label) ? collection.label : collection.to_s
42
+ return "Posts" if label == "posts"
43
+ return label.capitalize unless label.empty?
44
+
45
+ "Navigation"
46
+ end
47
+
48
+ def relative_url(site, url)
49
+ return url if url.start_with?("http://", "https://")
50
+
51
+ baseurl = site.config["baseurl"].to_s.chomp("/")
52
+ path = url.start_with?("/") ? url : "/#{url}"
53
+ "#{baseurl}#{path}"
54
+ end
55
+
56
+ def script(entries)
57
+ rows = entries.map do |entry|
58
+ <<~JS
59
+ {
60
+ id: #{JSON.generate(entry[:id])},
61
+ title: #{JSON.generate(entry[:title])},
62
+ description: #{JSON.generate(entry[:description])},
63
+ section: #{JSON.generate(entry[:section])},
64
+ handler: () => { window.location.href = #{JSON.generate(entry[:url])}; },
65
+ }
66
+ JS
67
+ end.join(",\n")
68
+
69
+ <<~JS
70
+ const ninja = document.querySelector("ninja-keys");
71
+ if (ninja) {
72
+ ninja.data = [
73
+ #{rows}
74
+ ];
75
+ }
76
+ JS
77
+ end
78
+ end
79
+
80
+ Hooks.register :site, :post_write do |site|
81
+ UnaltrawebSearchData.write(site)
82
+ end
83
+ end
@@ -0,0 +1,56 @@
1
+ # Make jekyll-cache-bust work when assets live in the unaltraweb gem theme
2
+ # instead of the child site repository.
3
+ require "jekyll-cache-bust"
4
+
5
+ module Jekyll
6
+ module CacheBust
7
+ class CacheDigester
8
+ UNALTRAWEB_ROOT = File.expand_path("..", __dir__)
9
+
10
+ private
11
+
12
+ def directory_files_content
13
+ return main_css_source_content if directory == "assets/_sass"
14
+
15
+ target_directory = existing_path(directory)
16
+ return "" unless target_directory
17
+
18
+ Dir[File.join(target_directory, "**", "*")].map do |file|
19
+ File.read(file) unless File.directory?(file)
20
+ end.join
21
+ end
22
+
23
+ def file_content
24
+ local_file_name = file_name.slice((file_name.index("assets/")..-1))
25
+ return main_css_source_content if local_file_name == "assets/css/main.css"
26
+
27
+ target_file = existing_path(local_file_name)
28
+ return "" unless target_file
29
+
30
+ File.read(target_file)
31
+ end
32
+
33
+ def main_css_source_content
34
+ files = existing_paths("assets/css/main.scss")
35
+ existing_paths("_sass").each do |sass_dir|
36
+ files.concat(Dir[File.join(sass_dir, "**", "*")].reject { |file| File.directory?(file) })
37
+ end
38
+ files.uniq.sort.map { |file| File.read(file) }.join
39
+ end
40
+
41
+ def existing_path(relative_path)
42
+ return nil if relative_path.nil? || relative_path.empty?
43
+
44
+ existing_paths(relative_path).first
45
+ end
46
+
47
+ def existing_paths(relative_path)
48
+ [
49
+ relative_path,
50
+ File.join("docs", relative_path),
51
+ File.join(UNALTRAWEB_ROOT, relative_path),
52
+ ].select { |path| File.exist?(path) }
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,290 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "psych"
5
+ require "yaml"
6
+
7
+ # Rewrites Vega-Lite and Vega specification image references to the single
8
+ # generated output declared for the source in .vegavisuals.yml.
9
+ module Unaltraweb
10
+ module VegaVisualImages
11
+ module_function
12
+
13
+ BASEURL_PREFIX = /\A\{\{\s*site\.baseurl\s*\}\}/.freeze
14
+ REMOTE_PATH_PREFIX = %r{\A(?:[a-z][a-z0-9+.-]*:)?//}i.freeze
15
+ FENCED_CODE_BLOCK = /^[ \t]*(`{3,}|~{3,})[^\n]*\n.*?^[ \t]*\1[ \t]*$/m.freeze
16
+ INLINE_CODE_SPAN = /(`+)[^\n]*?\1/.freeze
17
+ VEGA_PATH_SUFFIX = /\.(?:vl|vg)\.json\z/i.freeze
18
+ VEGA_DESTINATION = /(?:\{\{\s*site\.baseurl\s*\}\})?[^\s)"']*?\.(?:vl|vg)\.json(?:[?#][^\s)"']*)?/i.freeze
19
+ OUTPUT_SUFFIXES = %w[.svg .png .pdf].freeze
20
+ SAFE_NAME = /\A[A-Za-z0-9][A-Za-z0-9_.-]*\z/.freeze
21
+ MANIFEST_NAME = ".vegavisuals.yml"
22
+
23
+ def rewrite(content, site_source:)
24
+ return content if content.nil? || content.empty?
25
+
26
+ rewrite_outside_code_fences(content) { |chunk| rewrite_chunk(chunk, site_source: site_source) }
27
+ end
28
+
29
+ def rewrite_chunk(content, site_source:)
30
+ out = content.dup
31
+ out.gsub!(/!\[([^\]]*)\]\((#{VEGA_DESTINATION})(\s+(?:"[^"]*"|'[^']*'))?\)/i) do
32
+ alt = Regexp.last_match(1)
33
+ path = published_path_for(Regexp.last_match(2), site_source)
34
+ title = Regexp.last_match(3).to_s
35
+ "![#{alt}](#{path}#{title})"
36
+ end
37
+ out.gsub!(/(<img\b[^>]*\bsrc=)(["'])([^"']+)\2/i) do
38
+ prefix = Regexp.last_match(1)
39
+ quote = Regexp.last_match(2)
40
+ source_ref = Regexp.last_match(3)
41
+ next Regexp.last_match(0) unless vega_reference?(source_ref)
42
+
43
+ "#{prefix}#{quote}#{published_path_for(source_ref, site_source)}#{quote}"
44
+ end
45
+ out
46
+ end
47
+
48
+ def rewrite_outside_code_fences(content)
49
+ fences = []
50
+ protected_source = content.to_s.gsub(FENCED_CODE_BLOCK) do
51
+ token = "UNALTRAWEBVEGAVISUALCODE#{fences.length}"
52
+ fences << Regexp.last_match(0)
53
+ token
54
+ end
55
+ protected_source.gsub!(INLINE_CODE_SPAN) do
56
+ token = "UNALTRAWEBVEGAVISUALCODE#{fences.length}"
57
+ fences << Regexp.last_match(0)
58
+ token
59
+ end
60
+ transformed = yield(protected_source)
61
+ fences.each_index.reverse_each do |index|
62
+ transformed.gsub!("UNALTRAWEBVEGAVISUALCODE#{index}", fences[index])
63
+ end
64
+ transformed
65
+ end
66
+
67
+ def published_path_for(source_ref, site_source)
68
+ source_path, decoration = reference_parts(source_ref)
69
+ source_relative, = resolve_project_path(
70
+ source_path,
71
+ site_source,
72
+ label: "Vega visualization source",
73
+ must_exist: true
74
+ )
75
+ fatal("Vega visualization source must end in .vl.json or .vg.json: #{source_ref}") unless source_relative.match?(VEGA_PATH_SUFFIX)
76
+
77
+ entries = manifest_entries(site_source)
78
+ matches = entries.select { |entry| entry.fetch("source") == source_relative }
79
+ if matches.empty?
80
+ fatal("Vega visualization source is not declared in #{MANIFEST_NAME}: #{source_relative}")
81
+ end
82
+ if matches.length > 1
83
+ fatal("Vega visualization source is ambiguous in #{MANIFEST_NAME}: #{source_relative}")
84
+ end
85
+
86
+ output_relative = matches.first.fetch("output")
87
+ if File.extname(output_relative).downcase == ".pdf"
88
+ fatal("Vega visualization output cannot be embedded as a web image: #{output_relative}; use SVG or PNG")
89
+ end
90
+ _, output_local = resolve_project_path(
91
+ output_relative,
92
+ site_source,
93
+ label: "Vega visualization output",
94
+ must_exist: true
95
+ )
96
+ fatal("Missing rendered Vega visualization output: #{output_relative}") unless File.file?(output_local)
97
+ source_root = site_root(site_source)
98
+ unless inside_project?(output_local, source_root)
99
+ fatal("Vega visualization output is outside the Jekyll source directory: #{output_relative}")
100
+ end
101
+ published_relative = Pathname.new(output_local).relative_path_from(Pathname.new(source_root)).to_s
102
+
103
+ reference_for(source_ref, published_relative, decoration)
104
+ end
105
+
106
+ def manifest_entries(site_source)
107
+ root = project_root(site_source)
108
+ manifest_path = File.join(root, MANIFEST_NAME)
109
+ fatal("Missing required Vega visualization manifest: #{MANIFEST_NAME}") unless File.file?(manifest_path)
110
+ fatal("Vega visualization manifest exceeds 1048576 bytes: #{MANIFEST_NAME}") if File.size(manifest_path) > 1024 * 1024
111
+
112
+ text = File.binread(manifest_path).force_encoding(Encoding::UTF_8)
113
+ fatal("Invalid UTF-8 in Vega visualization manifest: #{MANIFEST_NAME}") unless text.valid_encoding?
114
+ reject_unsafe_yaml!(text)
115
+ value = YAML.safe_load(text, permitted_classes: [], permitted_symbols: [], aliases: false)
116
+ fatal("Vega visualization manifest must be a mapping: #{MANIFEST_NAME}") unless value.is_a?(Hash)
117
+ fatal("Vega visualization manifest version must be 1") unless value["version"] == 1
118
+ %w[profile family].each do |field|
119
+ fatal("Vega visualization manifest requires a #{field}") unless value[field].is_a?(String) && !value[field].empty?
120
+ end
121
+
122
+ visualizations = value["visualizations"]
123
+ fatal("Vega visualization manifest visualizations must be a list") unless visualizations.is_a?(Array)
124
+
125
+ names = {}
126
+ sources = {}
127
+ outputs = {}
128
+ visualizations.each_with_index.map do |item, index|
129
+ fatal("Vega visualization at index #{index} must be a mapping") unless item.is_a?(Hash)
130
+ name = item["name"]
131
+ fatal("Vega visualization at index #{index} has an invalid name") unless name.is_a?(String) && name.match?(SAFE_NAME)
132
+ fatal("Duplicate Vega visualization name: #{name}") if names[name]
133
+
134
+ source_relative, source_local = resolve_project_path(
135
+ item["source"],
136
+ root,
137
+ label: "source for Vega visualization #{name}",
138
+ must_exist: true
139
+ )
140
+ output_relative, output_local = resolve_project_path(
141
+ item["output"],
142
+ root,
143
+ label: "output for Vega visualization #{name}",
144
+ must_exist: false
145
+ )
146
+ fatal("Vega visualization #{name} output cannot replace its source") if source_local == output_local
147
+ fatal("Duplicate Vega visualization source: #{source_relative}") if sources[source_relative]
148
+ fatal("Duplicate Vega visualization output: #{output_relative}") if outputs[output_relative]
149
+
150
+ output_suffix = File.extname(output_relative).downcase
151
+ fatal("Vega visualization #{name} output must use .svg, .png, or .pdf") unless OUTPUT_SUFFIXES.include?(output_suffix)
152
+ format = item["format"]
153
+ fatal("Vega visualization #{name} format must be a string") unless format.nil? || format.is_a?(String)
154
+ if format && !format.empty? && ".#{format.strip.downcase}" != output_suffix
155
+ fatal("Vega visualization #{name} output suffix does not match format #{format}")
156
+ end
157
+ engine = item.fetch("engine", "auto")
158
+ fatal("Vega visualization #{name} engine must be a string") unless engine.is_a?(String)
159
+ inputs = item.fetch("inputs", [])
160
+ unless inputs.is_a?(Array) && inputs.all? { |entry| entry.is_a?(String) && !entry.empty? }
161
+ fatal("Vega visualization #{name} inputs must be a list of paths")
162
+ end
163
+
164
+ names[name] = true
165
+ sources[source_relative] = true
166
+ outputs[output_relative] = true
167
+ { "name" => name, "source" => source_relative, "output" => output_relative }
168
+ end
169
+ rescue Psych::Exception, SystemCallError => e
170
+ fatal("Invalid Vega visualization manifest #{MANIFEST_NAME}: #{e.message}")
171
+ end
172
+
173
+ def reject_unsafe_yaml!(text)
174
+ stream = Psych.parse_stream(text)
175
+ fatal("#{MANIFEST_NAME} must contain exactly one YAML document") unless stream.children.length == 1
176
+ inspect_yaml_node!(stream)
177
+ end
178
+
179
+ def inspect_yaml_node!(node)
180
+ fatal("YAML aliases are not allowed in #{MANIFEST_NAME}") if node.is_a?(Psych::Nodes::Alias)
181
+ if node.is_a?(Psych::Nodes::Mapping)
182
+ keys = {}
183
+ node.children.each_slice(2) do |key, value|
184
+ fatal("YAML mapping keys must be scalars in #{MANIFEST_NAME}") unless key.is_a?(Psych::Nodes::Scalar)
185
+ fatal("Duplicate YAML key in #{MANIFEST_NAME}: #{key.value}") if keys[key.value]
186
+
187
+ keys[key.value] = true
188
+ inspect_yaml_node!(value)
189
+ end
190
+ elsif node.respond_to?(:children)
191
+ Array(node.children).each { |child| inspect_yaml_node!(child) }
192
+ end
193
+ end
194
+
195
+ def vega_reference?(source_ref)
196
+ local = source_ref.to_s.sub(BASEURL_PREFIX, "")
197
+ path = local.split(/[?#]/, 2).first.to_s
198
+ path.match?(VEGA_PATH_SUFFIX)
199
+ end
200
+
201
+ def reference_parts(source_ref, strict: true)
202
+ local = source_ref.to_s.sub(BASEURL_PREFIX, "")
203
+ match = /\A([^?#]*)([?#].*)?\z/m.match(local)
204
+ return [nil, ""] unless match
205
+
206
+ path = match[1]
207
+ decoration = match[2].to_s
208
+ if path.empty? || path.match?(REMOTE_PATH_PREFIX)
209
+ fatal("Vega visualization source must be a local project path: #{source_ref}") if strict
210
+ return [nil, decoration]
211
+ end
212
+ [path.sub(%r{\A/+}, ""), decoration]
213
+ end
214
+
215
+ def reference_for(source_ref, relative_path, decoration)
216
+ baseurl = source_ref[BASEURL_PREFIX]
217
+ return "#{baseurl}/#{relative_path}#{decoration}" if baseurl
218
+ return "/#{relative_path}#{decoration}" if source_ref.start_with?("/")
219
+
220
+ "{{ site.baseurl }}/#{relative_path}#{decoration}"
221
+ end
222
+
223
+ def resolve_project_path(raw, site_source, label:, must_exist:)
224
+ fatal("#{label} must be a non-empty project-relative path") unless raw.is_a?(String) && !raw.strip.empty?
225
+ fatal("#{label} contains a null byte") if raw.include?("\0")
226
+ relative = Pathname.new(raw)
227
+ if relative.absolute? || relative.each_filename.include?("..") || raw.match?(REMOTE_PATH_PREFIX)
228
+ fatal("#{label} must be a safe project-relative path: #{raw}")
229
+ end
230
+
231
+ cleaned = relative.cleanpath.to_s
232
+ fatal("#{label} must be a safe project-relative path: #{raw}") if cleaned.empty? || cleaned == "."
233
+ root = project_root(site_source)
234
+ resolved = resolve_location(File.expand_path(cleaned, root))
235
+ fatal("#{label} escapes the project: #{raw}") unless inside_project?(resolved, root)
236
+ fatal("#{label} is not a file: #{raw}") if must_exist && !File.file?(resolved)
237
+
238
+ canonical = Pathname.new(resolved).relative_path_from(Pathname.new(root)).to_s
239
+ [canonical, resolved]
240
+ rescue ArgumentError, SystemCallError => e
241
+ fatal("Cannot resolve #{label}: #{raw} (#{e.message})")
242
+ end
243
+
244
+ def resolve_location(path)
245
+ return File.realpath(path) if File.exist?(path) || File.symlink?(path)
246
+
247
+ missing = []
248
+ cursor = path
249
+ until File.exist?(cursor) || File.symlink?(cursor)
250
+ parent = File.dirname(cursor)
251
+ raise Errno::ENOENT, path if parent == cursor
252
+
253
+ missing.unshift(File.basename(cursor))
254
+ cursor = parent
255
+ end
256
+ File.expand_path(File.join(File.realpath(cursor), *missing))
257
+ end
258
+
259
+ def project_root(site_source)
260
+ source = site_root(site_source)
261
+ working = File.realpath(Dir.pwd)
262
+ root = inside_project?(source, working) ? working : source
263
+ root
264
+ rescue SystemCallError => e
265
+ fatal("Cannot resolve Vega visualization project root #{site_source}: #{e.message}")
266
+ end
267
+
268
+ def site_root(site_source)
269
+ root = File.realpath(site_source)
270
+ fatal("Vega visualization project root is not a directory: #{site_source}") unless File.directory?(root)
271
+ root
272
+ rescue SystemCallError => e
273
+ fatal("Cannot resolve Jekyll source directory #{site_source}: #{e.message}")
274
+ end
275
+
276
+ def inside_project?(path, root)
277
+ path == root || path.start_with?("#{root}#{File::SEPARATOR}")
278
+ end
279
+
280
+ def fatal(message)
281
+ raise Jekyll::Errors::FatalException, message
282
+ end
283
+ end
284
+ end
285
+
286
+ Jekyll::Hooks.register [:documents, :pages], :pre_render do |doc|
287
+ next unless doc.respond_to?(:content) && doc.content
288
+
289
+ doc.content = Unaltraweb::VegaVisualImages.rewrite(doc.content, site_source: doc.site.source)
290
+ end if defined?(Jekyll::Hooks)
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rexml/document"
4
+
5
+ module Unaltraweb
6
+ module WebCaptureImages
7
+ module_function
8
+
9
+ BASEURL_PREFIX = /\A\{\{\s*site\.baseurl\s*\}\}/.freeze
10
+ REMOTE_PATH_PREFIX = %r{\A(?:[a-z]+:)?//}i.freeze
11
+ FENCED_CODE_BLOCK = /^[ \t]*(`{3,}|~{3,})[^\n]*\n.*?^[ \t]*\1[ \t]*$/m.freeze
12
+ CAPTURE_SOURCE = /\.capture\.ya?ml/i.freeze
13
+ ALLOWED_ELEMENTS = %w[svg g defs metadata title desc image rect path text tspan marker polygon polyline line circle ellipse clippath mask lineargradient radialgradient stop pattern use namedview grid page].freeze
14
+ ALLOWED_ATTRIBUTES = %w[id class version baseprofile xmlns xlink inkscape x y x1 y1 x2 y2 cx cy r rx ry width height viewbox preserveaspectratio d points transform fill stroke stroke-width stroke-linecap stroke-linejoin stroke-miterlimit stroke-dasharray stroke-dashoffset opacity fill-opacity stroke-opacity fill-rule clip-rule clip-path mask font-family font-size font-style font-weight text-anchor dominant-baseline marker-start marker-mid marker-end markerwidth markerheight refx refy orient offset stop-color stop-opacity patternunits patterncontentunits gradientunits gradienttransform spreadmethod href style data-selector groupmode label space role aria-label pagecolor bordercolor borderopacity objecttolerance gridtolerance guidetolerance showgrid showguides zoom current-layer document-units pagecheckerboard deskcolor units originx originy spacingx spacingy].freeze
15
+ ALLOWED_STYLE_PROPERTIES = %w[fill stroke stroke-width stroke-linecap stroke-linejoin stroke-miterlimit stroke-dasharray stroke-dashoffset opacity fill-opacity stroke-opacity fill-rule clip-rule clip-path mask font-family font-size font-style font-weight text-anchor dominant-baseline marker-start marker-mid marker-end stop-color stop-opacity display visibility].freeze
16
+
17
+ def rewrite(content, site_source:)
18
+ return content if content.nil? || content.empty?
19
+
20
+ rewrite_outside_code_fences(content) { |chunk| rewrite_chunk(chunk, site_source: site_source) }
21
+ end
22
+
23
+ def rewrite_chunk(content, site_source:)
24
+ out = content.dup
25
+ out.gsub!(/!\[([^\]]*)\]\(([^)]*?#{CAPTURE_SOURCE})(\s+"[^"]*")?\)/i) do
26
+ alt = Regexp.last_match(1)
27
+ path = svg_path_for(Regexp.last_match(2), site_source)
28
+ title = Regexp.last_match(3).to_s
29
+ "![#{alt}](#{path}#{title})"
30
+ end
31
+ out.gsub!(/(<img\b[^>]*\bsrc=)(["'])([^"']+?#{CAPTURE_SOURCE})\2/i) do
32
+ prefix = Regexp.last_match(1)
33
+ quote = Regexp.last_match(2)
34
+ path = svg_path_for(Regexp.last_match(3), site_source)
35
+ "#{prefix}#{quote}#{path}#{quote}"
36
+ end
37
+ out
38
+ end
39
+
40
+ def rewrite_outside_code_fences(content)
41
+ source = content.to_s
42
+ fences = []
43
+ protected_source = source.gsub(FENCED_CODE_BLOCK) do
44
+ token = "UNALTRAWEBWEBFENCEDCODEBLOCK#{fences.length}"
45
+ fences << Regexp.last_match(0)
46
+ token
47
+ end
48
+ transformed = yield(protected_source)
49
+ fences.each_index.reverse_each do |index|
50
+ transformed.gsub!("UNALTRAWEBWEBFENCEDCODEBLOCK#{index}", fences[index])
51
+ end
52
+ transformed
53
+ end
54
+
55
+ def svg_path_for(capture_path, site_source)
56
+ edited = capture_path.sub(/\.capture\.ya?ml/i, ".capture.edited.svg")
57
+ if local_asset_exists?(edited, site_source)
58
+ validate_svg!(local_asset_path(edited, site_source))
59
+ return edited
60
+ end
61
+
62
+ generated = capture_path.sub(/\.capture\.ya?ml/i, ".capture.svg")
63
+ local = local_asset_path(generated, site_source)
64
+ unless local && File.file?(local)
65
+ raise Jekyll::Errors::FatalException, "Missing rendered web capture SVG for #{capture_path}. Run make web-capture-render."
66
+ end
67
+ validate_svg!(local)
68
+ generated
69
+ end
70
+
71
+ def validate_svg!(path)
72
+ text = File.read(path, encoding: "UTF-8")
73
+ lowered = text.downcase
74
+ raise "unsafe XML declaration" if lowered.include?("<!doctype") || lowered.include?("<!entity")
75
+
76
+ document = REXML::Document.new(text)
77
+ document.elements.each("//*") do |element|
78
+ tag = element.name.to_s.downcase
79
+ raise "unsupported element #{tag}" unless ALLOWED_ELEMENTS.include?(tag)
80
+
81
+ element.attributes.each_attribute do |attribute|
82
+ name = attribute.expanded_name.to_s.split(":").last.downcase
83
+ value = attribute.value.to_s.strip
84
+ lowered_value = value.downcase
85
+ raise "unsupported attribute #{name}" unless ALLOWED_ATTRIBUTES.include?(name)
86
+ raise "unsafe attribute #{name}" if lowered_value.include?("@import") || lowered_value.include?("javascript:") || lowered_value.include?("expression(")
87
+ if name == "href" && !value.start_with?("data:image/png;base64,", "#")
88
+ raise "external SVG reference"
89
+ end
90
+ without_local_urls = lowered_value.gsub(/url\(\s*["']?#[A-Za-z_][-:.A-Za-z0-9_]*["']?\s*\)/, "")
91
+ if without_local_urls.include?("url(")
92
+ raise "external CSS reference"
93
+ end
94
+ if name == "style"
95
+ value.split(";").map(&:strip).reject(&:empty?).each do |declaration|
96
+ property, separator, = declaration.partition(":")
97
+ raise "unsupported style #{property.strip}" if separator.empty? || !ALLOWED_STYLE_PROPERTIES.include?(property.strip.downcase)
98
+ end
99
+ end
100
+ end
101
+ end
102
+ rescue REXML::ParseException, RuntimeError => e
103
+ raise Jekyll::Errors::FatalException, "Unsafe web capture SVG #{path}: #{e.message}"
104
+ end
105
+
106
+ def local_asset_exists?(asset_path, site_source)
107
+ local = local_asset_path(asset_path, site_source)
108
+ local && File.file?(local)
109
+ end
110
+
111
+ def local_asset_path(asset_path, site_source)
112
+ local_path = asset_path
113
+ .sub(BASEURL_PREFIX, "")
114
+ .split(/[?#]/, 2)
115
+ .first.to_s
116
+ .sub(%r{\A/+}, "")
117
+ return nil if local_path.empty? || local_path.match?(REMOTE_PATH_PREFIX)
118
+
119
+ root = File.expand_path(site_source)
120
+ path = File.expand_path(local_path, root)
121
+ return nil unless path == root || path.start_with?("#{root}/")
122
+
123
+ path
124
+ end
125
+
126
+ Jekyll::Hooks.register :site, :post_read do |site|
127
+ site.static_files.reject! do |file|
128
+ relative = file.relative_path.to_s.downcase
129
+ if relative.end_with?(".capture.svg")
130
+ source = file.path.sub(/\.capture\.svg\z/i, ".capture.yml")
131
+ source_yaml = file.path.sub(/\.capture\.svg\z/i, ".capture.yaml")
132
+ edited = file.path.sub(/\.capture\.svg\z/i, ".capture.edited.svg")
133
+ remove = (!File.file?(source) && !File.file?(source_yaml)) || File.file?(edited)
134
+ validate_svg!(file.path) unless remove
135
+ remove
136
+ elsif relative.end_with?(".capture.edited.svg")
137
+ source = file.path.sub(/\.capture\.edited\.svg\z/i, ".capture.yml")
138
+ source_yaml = file.path.sub(/\.capture\.edited\.svg\z/i, ".capture.yaml")
139
+ remove = !File.file?(source) && !File.file?(source_yaml)
140
+ validate_svg!(file.path) unless remove
141
+ remove
142
+ else
143
+ relative.end_with?(".capture.yml", ".capture.yaml", ".capture.png")
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
149
+
150
+ Jekyll::Hooks.register [:documents, :pages], :pre_render do |doc|
151
+ next unless doc.respond_to?(:content) && doc.content
152
+
153
+ doc.content = Unaltraweb::WebCaptureImages.rewrite(doc.content, site_source: doc.site.source)
154
+ end if defined?(Jekyll::Hooks)