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.
- checksums.yaml +7 -0
- data/LICENSE +20 -0
- data/Makefile +412 -0
- data/README.md +226 -0
- data/_config.yml +842 -0
- data/_data/i18n/ca.yml +278 -0
- data/_data/i18n/en.yml +278 -0
- data/_data/i18n/es.yml +278 -0
- data/_includes/audio.liquid +30 -0
- data/_includes/bib_search.liquid +5 -0
- data/_includes/blog-list.liquid +66 -0
- data/_includes/book-citation.liquid +33 -0
- data/_includes/citation.liquid +26 -0
- data/_includes/custom_head.liquid +3 -0
- data/_includes/custom_scripts.liquid +5 -0
- data/_includes/cv/list.liquid +5 -0
- data/_includes/cv/list_groups.liquid +51 -0
- data/_includes/cv/map.liquid +29 -0
- data/_includes/cv/nested_list.liquid +16 -0
- data/_includes/cv/time_table.liquid +118 -0
- data/_includes/cv-download-card.liquid +26 -0
- data/_includes/developer-mode-switcher.liquid +24 -0
- data/_includes/disqus.liquid +13 -0
- data/_includes/distill_scripts.liquid +298 -0
- data/_includes/documentation-download-detail.liquid +76 -0
- data/_includes/documentation-downloads.liquid +71 -0
- data/_includes/documentation-profile-attributes.liquid +4 -0
- data/_includes/documentation-section-toc.liquid +9 -0
- data/_includes/documentation-sidebar.liquid +112 -0
- data/_includes/figure.liquid +87 -0
- data/_includes/footer.liquid +66 -0
- data/_includes/giscus.liquid +25 -0
- data/_includes/head.liquid +220 -0
- data/_includes/header.liquid +544 -0
- data/_includes/latest_posts.liquid +61 -0
- data/_includes/localized-date.liquid +5 -0
- data/_includes/manual-bibliography.liquid +2 -0
- data/_includes/manual-featured-readings.liquid +12 -0
- data/_includes/manual-other-readings.liquid +12 -0
- data/_includes/manual-page-toc.liquid +11 -0
- data/_includes/manual-sidebar.liquid +58 -0
- data/_includes/manual-teachers.liquid +74 -0
- data/_includes/metadata.liquid +278 -0
- data/_includes/news.liquid +48 -0
- data/_includes/newsletter.liquid +68 -0
- data/_includes/page-hero.liquid +96 -0
- data/_includes/page-link.liquid +8 -0
- data/_includes/pagination.liquid +22 -0
- data/_includes/post-reading-time.liquid +29 -0
- data/_includes/previous-next.liquid +37 -0
- data/_includes/profile-card.liquid +49 -0
- data/_includes/profile-feature-matrix.liquid +75 -0
- data/_includes/profile-highlights.liquid +122 -0
- data/_includes/project-grid.liquid +26 -0
- data/_includes/project-resource-link.liquid +45 -0
- data/_includes/project-resources.liquid +21 -0
- data/_includes/projects.liquid +38 -0
- data/_includes/projects_horizontal.liquid +37 -0
- data/_includes/publications-metrics-summary.liquid +69 -0
- data/_includes/publications-summary.liquid +127 -0
- data/_includes/reading-biblio-controls.liquid +65 -0
- data/_includes/reading-cover-card.liquid +93 -0
- data/_includes/reading-rating.liquid +11 -0
- data/_includes/recommended-readings.liquid +30 -0
- data/_includes/related_posts.liquid +39 -0
- data/_includes/repository/repo.liquid +48 -0
- data/_includes/repository/repo_trophies.liquid +44 -0
- data/_includes/repository/repo_user.liquid +35 -0
- data/_includes/resume/awards.liquid +19 -0
- data/_includes/resume/basics.liquid +28 -0
- data/_includes/resume/certificates.liquid +35 -0
- data/_includes/resume/education.liquid +54 -0
- data/_includes/resume/interests.liquid +34 -0
- data/_includes/resume/languages.liquid +31 -0
- data/_includes/resume/projects.liquid +32 -0
- data/_includes/resume/publications.liquid +28 -0
- data/_includes/resume/references.liquid +31 -0
- data/_includes/resume/skills.liquid +33 -0
- data/_includes/resume/volunteer.liquid +52 -0
- data/_includes/resume/work.liquid +52 -0
- data/_includes/scripts.liquid +364 -0
- data/_includes/search-data.js.liquid +341 -0
- data/_includes/selected_papers.liquid +3 -0
- data/_includes/t.liquid +31 -0
- data/_includes/team-grid.liquid +172 -0
- data/_includes/video.liquid +97 -0
- data/_layouts/about.liquid +93 -0
- data/_layouts/archive.liquid +45 -0
- data/_layouts/bib.liquid +677 -0
- data/_layouts/book-review.liquid +225 -0
- data/_layouts/book-shelf.liquid +66 -0
- data/_layouts/cv.liquid +127 -0
- data/_layouts/default.liquid +70 -0
- data/_layouts/distill.liquid +132 -0
- data/_layouts/documentation-download-detail.liquid +5 -0
- data/_layouts/documentation-home.liquid +100 -0
- data/_layouts/documentation-page.liquid +45 -0
- data/_layouts/manual-bib.liquid +73 -0
- data/_layouts/manual-chapter.liquid +99 -0
- data/_layouts/manual-featured-bib.liquid +100 -0
- data/_layouts/manual-home.liquid +120 -0
- data/_layouts/none.liquid +1 -0
- data/_layouts/outputs.liquid +29 -0
- data/_layouts/page.liquid +52 -0
- data/_layouts/post.liquid +95 -0
- data/_layouts/profile.liquid +36 -0
- data/_layouts/profiles.liquid +36 -0
- data/_layouts/project.liquid +7 -0
- data/_layouts/repositories.liquid +58 -0
- data/_layouts/theses.liquid +126 -0
- data/_plugins/bibliography_profiles.rb +164 -0
- data/_plugins/callouts.rb +104 -0
- data/_plugins/code_blocks.rb +277 -0
- data/_plugins/computation_figure_images.rb +266 -0
- data/_plugins/computation_sources.rb +42 -0
- data/_plugins/content_search_index.rb +210 -0
- data/_plugins/details.rb +23 -0
- data/_plugins/external-posts.rb +124 -0
- data/_plugins/figure_captions.rb +800 -0
- data/_plugins/file-exists.rb +22 -0
- data/_plugins/google-scholar-citations.rb +86 -0
- data/_plugins/hide-custom-bibtex.rb +18 -0
- data/_plugins/inspirehep-citations.rb +57 -0
- data/_plugins/localized_visual_sources.rb +145 -0
- data/_plugins/manual_release_metadata.rb +47 -0
- data/_plugins/mermaid_mmd_images.rb +233 -0
- data/_plugins/profile-pages.rb +28 -0
- data/_plugins/remove-accents.rb +32 -0
- data/_plugins/reproducible_build_time.rb +55 -0
- data/_plugins/search-data.rb +83 -0
- data/_plugins/theme-cache-bust.rb +56 -0
- data/_plugins/vega_visual_images.rb +290 -0
- data/_plugins/web_capture_images.rb +154 -0
- data/_sass/_base.scss +2133 -0
- data/_sass/_blog.scss +67 -0
- data/_sass/_callouts.scss +100 -0
- data/_sass/_custom.scss +437 -0
- data/_sass/_cv.scss +274 -0
- data/_sass/_developer.scss +97 -0
- data/_sass/_distill.scss +185 -0
- data/_sass/_documentation.scss +2220 -0
- data/_sass/_layout.scss +218 -0
- data/_sass/_manual.scss +2691 -0
- data/_sass/_profile.scss +323 -0
- data/_sass/_projects.scss +163 -0
- data/_sass/_reading.scss +465 -0
- data/_sass/_site-custom.scss +3 -0
- data/_sass/_tabs.scss +48 -0
- data/_sass/_team.scss +230 -0
- data/_sass/_themes.scss +254 -0
- data/_sass/_typograms.scss +132 -0
- data/_sass/_variables.scss +52 -0
- data/_sass/font-awesome/_animated.scss +152 -0
- data/_sass/font-awesome/_bordered-pulled.scss +20 -0
- data/_sass/font-awesome/_core.scss +49 -0
- data/_sass/font-awesome/_fixed-width.scss +7 -0
- data/_sass/font-awesome/_functions.scss +57 -0
- data/_sass/font-awesome/_icons.scss +13 -0
- data/_sass/font-awesome/_list.scss +18 -0
- data/_sass/font-awesome/_mixins.scss +65 -0
- data/_sass/font-awesome/_rotated-flipped.scss +31 -0
- data/_sass/font-awesome/_screen-reader.scss +14 -0
- data/_sass/font-awesome/_shims.scss +1578 -0
- data/_sass/font-awesome/_sizing.scss +16 -0
- data/_sass/font-awesome/_stacked.scss +32 -0
- data/_sass/font-awesome/_variables.scss +5044 -0
- data/_sass/font-awesome/brands.scss +30 -0
- data/_sass/font-awesome/fontawesome.scss +21 -0
- data/_sass/font-awesome/regular.scss +27 -0
- data/_sass/font-awesome/solid.scss +27 -0
- data/_sass/font-awesome/v4-shims.scss +11 -0
- data/_sass/tabler-icons/tabler-icons-filled.scss +1646 -0
- data/_sass/tabler-icons/tabler-icons-outline.scss +9859 -0
- data/_sass/tabler-icons/tabler-icons.scss +11463 -0
- data/_scripts/cronitor-analytics-setup.js +9 -0
- data/_scripts/giscus-setup.js +48 -0
- data/_scripts/google-analytics-setup.js +9 -0
- data/_scripts/open-panel-analytics-setup.js +14 -0
- data/_scripts/photoswipe-setup.js +11 -0
- data/assets/audio/epicaly-short-113909.mp3 +0 -0
- data/assets/bibliography/2018-12-22-distill.bib +7 -0
- data/assets/css/academicons.min.css +1 -0
- data/assets/css/bootstrap-toc.min.css +4 -0
- data/assets/css/bootstrap.min.css +7 -0
- data/assets/css/bootstrap.min.css.map +1 -0
- data/assets/css/jekyll-pygments-themes-github.css +209 -0
- data/assets/css/jekyll-pygments-themes-native.css +232 -0
- data/assets/css/jupyter-grade3.css +3758 -0
- data/assets/css/jupyter-monokai.css +3758 -0
- data/assets/css/jupyter.css +383 -0
- data/assets/css/main.scss +36 -0
- data/assets/css/mdb.min.css +48 -0
- data/assets/css/mdb.min.css.map +1 -0
- data/assets/css/scholar-icons.css +386 -0
- data/assets/fonts/academicons.ttf +0 -0
- data/assets/fonts/academicons.woff +0 -0
- data/assets/fonts/scholar-icons.ttf +0 -0
- data/assets/fonts/scholar-icons.woff +0 -0
- data/assets/fonts/tabler-icons-filled.ttf +0 -0
- data/assets/fonts/tabler-icons-filled.woff +0 -0
- data/assets/fonts/tabler-icons-filled.woff2 +0 -0
- data/assets/fonts/tabler-icons-outline.ttf +0 -0
- data/assets/fonts/tabler-icons-outline.woff +0 -0
- data/assets/fonts/tabler-icons-outline.woff2 +0 -0
- data/assets/fonts/tabler-icons.ttf +0 -0
- data/assets/fonts/tabler-icons.woff +0 -0
- data/assets/fonts/tabler-icons.woff2 +0 -0
- data/assets/html/relativity.html +15 -0
- data/assets/img/1.jpg +0 -0
- data/assets/img/10.jpg +0 -0
- data/assets/img/11.jpg +0 -0
- data/assets/img/12.jpg +0 -0
- data/assets/img/2.jpg +0 -0
- data/assets/img/3.jpg +0 -0
- data/assets/img/4.jpg +0 -0
- data/assets/img/5.jpg +0 -0
- data/assets/img/6.jpg +0 -0
- data/assets/img/7.jpg +0 -0
- data/assets/img/8.jpg +0 -0
- data/assets/img/9.jpg +0 -0
- data/assets/img/book_covers/the_godfather.jpg +0 -0
- data/assets/img/brand/dosquartsdedocs-logo-cafe.svg +31 -0
- data/assets/img/brand/dosquartsdedocs-logo-footer-cafe.svg +31 -0
- data/assets/img/brand/dosquartsdedocs-logo-white.svg +31 -0
- data/assets/img/brand/dosquartsdedocs-logo.svg +31 -0
- data/assets/img/prof_pic.jpg +0 -0
- data/assets/img/prof_pic_color.png +0 -0
- data/assets/img/profile-placeholder.svg +8 -0
- data/assets/img/publication_preview/brownian-motion.gif +0 -0
- data/assets/img/publication_preview/goodchild1992-gis-cover.jpg +0 -0
- data/assets/img/publication_preview/gutierrez2020-jtrg-preview.jpg +0 -0
- data/assets/img/publication_preview/harvey1989-postmodernity-cover.jpg +0 -0
- data/assets/img/publication_preview/tobler1970-economic-geography-cover.jpg +0 -0
- data/assets/img/publication_preview/tuan1977-space-place-cover.jpg +0 -0
- data/assets/img/publication_preview/wave-mechanics.gif +0 -0
- data/assets/img/publication_preview/zaragozi2025-rooftop-pv-preview.jpg +0 -0
- data/assets/img/rhino.png +0 -0
- data/assets/img/team/aaron-gutierrez.png +0 -0
- data/assets/img/team/alba-font.png +0 -0
- data/assets/img/team/alba-viana.png +0 -0
- data/assets/img/team/alejandro-gonzalez.png +0 -0
- data/assets/img/team/ana-pastor.png +0 -0
- data/assets/img/team/antoni-domenech.png +0 -0
- data/assets/img/team/benito-zaragozi.png +0 -0
- data/assets/img/team/cinta-sanz.png +0 -0
- data/assets/img/team/daniel-miravet.png +0 -0
- data/assets/img/team/eugenia-altamirano.png +0 -0
- data/assets/img/team/fiammetta-brandajs.png +0 -0
- data/assets/img/team/francesc-gonzalez.png +0 -0
- data/assets/img/team/isabel-paulino.png +0 -0
- data/assets/img/team/joan-borras.png +0 -0
- data/assets/img/team/julie-wilson.png +0 -0
- data/assets/img/team/liliana-sole.png +0 -0
- data/assets/img/team/maria-casado.png +0 -0
- data/assets/img/team/maria-medina.png +0 -0
- data/assets/img/team/marta-nello.png +0 -0
- data/assets/img/team/mogrify +1 -0
- data/assets/img/team/oscar-saladie.png +0 -0
- data/assets/img/team/paolo-russo.png +0 -0
- data/assets/img/team/raquel-santos.png +0 -0
- data/assets/img/team/salvador-anton.png +0 -0
- data/assets/img/team/sunsi-huertas.png +0 -0
- data/assets/img/team/user-placeholder.png +0 -0
- data/assets/img/team/wilbert-den-hoed.png +0 -0
- data/assets/img/team/xavier-delclos.png +0 -0
- data/assets/img/template_error.png +0 -0
- data/assets/js/bibsearch.js +70 -0
- data/assets/js/bootstrap-toc.min.js +5 -0
- data/assets/js/bootstrap.bundle.min.js +7 -0
- data/assets/js/bootstrap.bundle.min.js.map +1 -0
- data/assets/js/callouts.js +115 -0
- data/assets/js/chartjs-setup.js +14 -0
- data/assets/js/common.js +117 -0
- data/assets/js/content-search-match.js +159 -0
- data/assets/js/content-search.js +441 -0
- data/assets/js/copy_code.js +60 -0
- data/assets/js/custom.js +6 -0
- data/assets/js/developer-mode.js +103 -0
- data/assets/js/diff2html-setup.js +20 -0
- data/assets/js/distillpub/overrides.js +27 -0
- data/assets/js/distillpub/template.v2.js +9616 -0
- data/assets/js/distillpub/template.v2.js.map +1 -0
- data/assets/js/distillpub/transforms.v2.js +14557 -0
- data/assets/js/distillpub/transforms.v2.js.map +1 -0
- data/assets/js/documentation.js +435 -0
- data/assets/js/echarts-setup.js +29 -0
- data/assets/js/highlight-search-term.js +110 -0
- data/assets/js/jupyter_new_tab.js +18 -0
- data/assets/js/leaflet-setup.js +22 -0
- data/assets/js/manual.js +308 -0
- data/assets/js/masonry.js +12 -0
- data/assets/js/mathjax-setup.js +46 -0
- data/assets/js/mermaid-setup.js +37 -0
- data/assets/js/newsletter.js +105 -0
- data/assets/js/no_defer.js +24 -0
- data/assets/js/plotly-setup.js +52 -0
- data/assets/js/profile-nav.js +92 -0
- data/assets/js/progress-bar.js +73 -0
- data/assets/js/pseudocode-setup.js +18 -0
- data/assets/js/search/base-styles.min.js +286 -0
- data/assets/js/search/command-score.min.js +7 -0
- data/assets/js/search/hotkeys-js/hotkeys.esm.min.js +18 -0
- data/assets/js/search/lit/decorators.min.js +7 -0
- data/assets/js/search/lit/directives/class-map.min.js +7 -0
- data/assets/js/search/lit/directives/join.min.js +7 -0
- data/assets/js/search/lit/directives/live.min.js +7 -0
- data/assets/js/search/lit/directives/ref.min.js +7 -0
- data/assets/js/search/lit/directives/repeat.min.js +7 -0
- data/assets/js/search/lit/directives/unsafe-html.min.js +7 -0
- data/assets/js/search/lit/index.min.js +7 -0
- data/assets/js/search/lit-element/lit-element.min.js +12 -0
- data/assets/js/search/lit-html/directives/class-map.min.js +22 -0
- data/assets/js/search/lit-html/directives/join.min.js +12 -0
- data/assets/js/search/lit-html/directives/live.min.js +28 -0
- data/assets/js/search/lit-html/directives/ref.min.js +32 -0
- data/assets/js/search/lit-html/directives/repeat.min.js +27 -0
- data/assets/js/search/lit-html/directives/unsafe-html.min.js +22 -0
- data/assets/js/search/lit-html/is-server.min.js +12 -0
- data/assets/js/search/lit-html/lit-html.min.js +12 -0
- data/assets/js/search/ninja-action.min.js +101 -0
- data/assets/js/search/ninja-footer.min.js +62 -0
- data/assets/js/search/ninja-header.min.js +78 -0
- data/assets/js/search/ninja-keys.min.js +39 -0
- data/assets/js/search/reactive-element/decorators/custom-element.min.js +12 -0
- data/assets/js/search/reactive-element/decorators/event-options.min.js +12 -0
- data/assets/js/search/reactive-element/decorators/property.min.js +22 -0
- data/assets/js/search/reactive-element/decorators/query-all.min.js +17 -0
- data/assets/js/search/reactive-element/decorators/query-assigned-elements.min.js +17 -0
- data/assets/js/search/reactive-element/decorators/query-assigned-nodes.min.js +17 -0
- data/assets/js/search/reactive-element/decorators/query-async.min.js +17 -0
- data/assets/js/search/reactive-element/decorators/query.min.js +17 -0
- data/assets/js/search/reactive-element/decorators/state.min.js +27 -0
- data/assets/js/search/reactive-element/reactive-element.min.js +17 -0
- data/assets/js/search-setup.js +17 -0
- data/assets/js/shortcut-key.js +11 -0
- data/assets/js/tabs.min.js +5 -0
- data/assets/js/theme.js +314 -0
- data/assets/js/tooltips-setup.js +3 -0
- data/assets/js/typograms.js +1341 -0
- data/assets/js/vanilla-back-to-top.min.js +1 -0
- data/assets/js/vega-setup.js +24 -0
- data/assets/js/venobox-setup.js +5 -0
- data/assets/js/wechat.js +14 -0
- data/assets/js/zoom.js +15 -0
- data/assets/json/resume.json +98 -0
- data/assets/json/table_data.json +128 -0
- data/assets/jupyter/blog.ipynb +48 -0
- data/assets/pdf/example_pdf.pdf +0 -0
- data/assets/plotly/demo.html +72 -0
- data/assets/video/pexels-engin-akyurt-6069112-960x540-30fps.mp4 +0 -0
- data/assets/webfonts/fa-brands-400.ttf +0 -0
- data/assets/webfonts/fa-brands-400.woff2 +0 -0
- data/assets/webfonts/fa-regular-400.ttf +0 -0
- data/assets/webfonts/fa-regular-400.woff2 +0 -0
- data/assets/webfonts/fa-solid-900.ttf +0 -0
- data/assets/webfonts/fa-solid-900.woff2 +0 -0
- data/assets/webfonts/fa-v4compatibility.ttf +0 -0
- data/assets/webfonts/fa-v4compatibility.woff2 +0 -0
- data/docs/Gemfile +5 -0
- data/docs/_config.yml +83 -0
- data/docs/_documentation/en/01-quick-start.md +115 -0
- data/docs/_documentation/en/02-tools.md +146 -0
- data/docs/_documentation/en/03-usage.md +107 -0
- data/docs/_documentation/en/04-jekyll-basics.md +51 -0
- data/docs/_documentation/en/05-pages-collections.md +68 -0
- data/docs/_documentation/en/06-github-web-editing.md +92 -0
- data/docs/_documentation/en/10-profiles.md +125 -0
- data/docs/_documentation/en/11-unaltreselfie.md +39 -0
- data/docs/_documentation/en/12-unaltreprojecte.md +38 -0
- data/docs/_documentation/en/13-unaltremanual.md +237 -0
- data/docs/_documentation/en/14-unaltredocs.md +92 -0
- data/docs/_documentation/en/15-profile-checklist.md +39 -0
- data/docs/_documentation/en/20-syntax.md +353 -0
- data/docs/_documentation/en/21-themes.md +103 -0
- data/docs/_documentation/en/22-feature-reference.md +56 -0
- data/docs/_documentation/en/23-front-matter.md +51 -0
- data/docs/_documentation/en/24-data-files.md +28 -0
- data/docs/_documentation/en/30-customization.md +448 -0
- data/docs/_documentation/en/31-template.md +55 -0
- data/docs/_documentation/en/32-development.md +108 -0
- data/docs/_documentation/en/33-reusable-workflows.md +136 -0
- data/docs/_documentation/en/40-distribution.md +164 -0
- data/docs/_documentation/en/41-bibliometrics.md +137 -0
- data/docs/_documentation/en/42-docker-image.md +185 -0
- data/docs/_documentation/en/50-faq.md +33 -0
- data/docs/_pages/ca/index.md +35 -0
- data/docs/_pages/en/index.md +35 -0
- data/docs/_pages/es/index.md +35 -0
- data/docs/agents/action-prompts/00-start-site-session.txt +10 -0
- data/docs/agents/action-prompts/05-create-new-web.txt +5 -0
- data/docs/agents/action-prompts/10-content-update.txt +9 -0
- data/docs/agents/action-prompts/15-edit-default-content.txt +8 -0
- data/docs/agents/action-prompts/20-manual-teaching-materials.txt +18 -0
- data/docs/agents/action-prompts/22-manual-style-audit.txt +14 -0
- data/docs/agents/action-prompts/23-manual-structure-audit.txt +17 -0
- data/docs/agents/action-prompts/25-translation-prepublish.txt +8 -0
- data/docs/agents/action-prompts/30-project-site-update.txt +7 -0
- data/docs/agents/action-prompts/40-documentation-update.txt +7 -0
- data/docs/agents/action-prompts/50-bibliography-entry.txt +7 -0
- data/docs/agents/action-prompts/60-bibliometrics-refresh.txt +7 -0
- data/docs/agents/action-prompts/70-build-and-review.txt +12 -0
- data/docs/agents/manual-authoring-components.md +255 -0
- data/docs/agents/mcp-contract.md +162 -0
- data/docs/agents/quarto-computation-adoption.md +102 -0
- data/docs/assets/img/unaltraweb-docs-hero.svg +36 -0
- data/lib/unaltraweb/version.rb +8 -0
- data/lib/unaltraweb.rb +31 -0
- data/requirements.txt +5 -0
- data/scripts/biblio/fetch_scimago_csv.sh +126 -0
- data/scripts/biblio/metrics_common.py +154 -0
- data/scripts/biblio/metrics_merge_meta.py +73 -0
- data/scripts/biblio/metrics_update.py +826 -0
- data/scripts/computations/python/Dockerfile +37 -0
- data/scripts/computations/r/Dockerfile +31 -0
- data/scripts/computations/render.py +1267 -0
- data/scripts/cv/render_pdf_preview.sh +36 -0
- data/scripts/deploy/publish_branch.sh +80 -0
- data/scripts/generate_logo_variants.py +47 -0
- data/scripts/manual/Dockerfile +31 -0
- data/scripts/manual/build_pdf.py +1780 -0
- data/scripts/manual/filters/bibliography.lua +164 -0
- data/scripts/manual/filters/code-blocks.lua +102 -0
- data/scripts/manual/filters/figure-captions.lua +35 -0
- data/scripts/manual/publish_release.sh +281 -0
- data/scripts/manual/templates/manual.tex +494 -0
- data/scripts/manual/verify_release_assets.py +650 -0
- data/scripts/test_gem_build.py +128 -0
- data/scripts/test_reproducible_jekyll_build.py +191 -0
- data/scripts/test_wheel_install.py +282 -0
- data/scripts/unaltraweb-docker-mount.sh +36 -0
- data/scripts/unaltraweb-mcp-bootstrap.sh +105 -0
- data/scripts/unaltraweb-mcp-cleanup.sh +73 -0
- data/scripts/unaltraweb-mcp-project-id.sh +34 -0
- data/scripts/validate_distribution.py +563 -0
- data/scripts/validate_workflows.py +1285 -0
- data/scripts/web_captures/Dockerfile +31 -0
- data/scripts/web_captures/capture.mjs +157 -0
- data/scripts/web_captures/package-lock.json +56 -0
- data/scripts/web_captures/package.json +9 -0
- data/scripts/web_captures/render.py +867 -0
- data/src/unaltraweb_mcp/component-contract.json +153 -0
- data/src/unaltraweb_mcp/component-contract.schema.json +87 -0
- data/src/unaltraweb_mcp/docker_mount.py +19 -0
- 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
|
+
""
|
|
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
|
+
""
|
|
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)
|