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,867 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Capture local web previews as versioned PNG and annotated SVG artefacts."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import base64
|
|
8
|
+
import contextlib
|
|
9
|
+
import datetime as dt
|
|
10
|
+
import fcntl
|
|
11
|
+
import hashlib
|
|
12
|
+
import html
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import secrets
|
|
17
|
+
import shutil
|
|
18
|
+
import struct
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
import tempfile
|
|
22
|
+
import xml.etree.ElementTree as ET
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
from urllib.parse import urlsplit
|
|
26
|
+
|
|
27
|
+
FACTORY_SRC = Path(__file__).resolve().parents[2] / "src"
|
|
28
|
+
sys.path.insert(0, str(FACTORY_SRC))
|
|
29
|
+
|
|
30
|
+
from unaltraweb_mcp.docker_mount import docker_bind_mount
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
import yaml
|
|
34
|
+
except ImportError as exc: # pragma: no cover - supplied by project tooling
|
|
35
|
+
raise SystemExit("PyYAML is required for unaltraweb web captures.") from exc
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
LOCK_PATH = Path(".unaltraweb/web-captures.lock.json")
|
|
39
|
+
SOURCE_SUFFIXES = (".capture.yml", ".capture.yaml")
|
|
40
|
+
DEFAULT_IMAGE = "ghcr.io/dosquartsdedocs/unaltraweb-web-capture:0.3.0"
|
|
41
|
+
DEFAULT_VIEWPORT = {"width": 1440, "height": 900, "device_scale_factor": 1}
|
|
42
|
+
CAPTURE_ID_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,79}$")
|
|
43
|
+
SAFE_SOURCE_RE = re.compile(r"^[A-Za-z0-9_./-]+\.capture\.ya?ml$")
|
|
44
|
+
SVG_METADATA_RE = re.compile(r'<metadata\s+id="unaltraweb-capture">(.*?)</metadata>', re.DOTALL)
|
|
45
|
+
SVG_ALLOWED_ELEMENTS = {
|
|
46
|
+
"svg", "g", "defs", "metadata", "title", "desc", "image", "rect", "path", "text", "tspan",
|
|
47
|
+
"marker", "polygon", "polyline", "line", "circle", "ellipse", "clippath", "mask", "lineargradient",
|
|
48
|
+
"radialgradient", "stop", "pattern", "use", "namedview", "grid", "page",
|
|
49
|
+
}
|
|
50
|
+
SVG_ALLOWED_ATTRIBUTES = {
|
|
51
|
+
"id", "class", "version", "baseprofile", "x", "y", "x1", "y1", "x2", "y2", "cx", "cy", "r", "rx", "ry",
|
|
52
|
+
"width", "height", "viewbox", "preserveaspectratio", "d", "points", "transform", "fill", "stroke", "stroke-width",
|
|
53
|
+
"stroke-linecap", "stroke-linejoin", "stroke-miterlimit", "stroke-dasharray", "stroke-dashoffset", "opacity", "fill-opacity",
|
|
54
|
+
"stroke-opacity", "fill-rule", "clip-rule", "clip-path", "mask", "font-family", "font-size", "font-style", "font-weight",
|
|
55
|
+
"text-anchor", "dominant-baseline", "marker-start", "marker-mid", "marker-end", "markerwidth", "markerheight", "refx", "refy",
|
|
56
|
+
"orient", "offset", "stop-color", "stop-opacity", "patternunits", "patterncontentunits", "gradientunits", "gradienttransform",
|
|
57
|
+
"spreadmethod", "href", "style", "data-selector", "groupmode", "label", "space", "role", "aria-label", "pagecolor",
|
|
58
|
+
"bordercolor", "borderopacity", "objecttolerance", "gridtolerance", "guidetolerance", "showgrid", "showguides", "zoom",
|
|
59
|
+
"current-layer", "document-units", "pagecheckerboard", "deskcolor", "units", "originx", "originy", "spacingx", "spacingy",
|
|
60
|
+
}
|
|
61
|
+
SVG_ALLOWED_STYLE_PROPERTIES = {
|
|
62
|
+
"fill", "stroke", "stroke-width", "stroke-linecap", "stroke-linejoin", "stroke-miterlimit", "stroke-dasharray",
|
|
63
|
+
"stroke-dashoffset", "opacity", "fill-opacity", "stroke-opacity", "fill-rule", "clip-rule", "clip-path", "mask",
|
|
64
|
+
"font-family", "font-size", "font-style", "font-weight", "text-anchor", "dominant-baseline", "marker-start", "marker-mid",
|
|
65
|
+
"marker-end", "stop-color", "stop-opacity", "display", "visibility",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class WebCaptureError(RuntimeError):
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def utc_now() -> str:
|
|
74
|
+
return dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def json_dump(value: Any) -> str:
|
|
78
|
+
return json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def canonical_json(value: Any) -> bytes:
|
|
82
|
+
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def sha256_bytes(value: bytes) -> str:
|
|
86
|
+
return hashlib.sha256(value).hexdigest()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def file_signature(path: Path) -> dict[str, Any]:
|
|
90
|
+
data = path.read_bytes()
|
|
91
|
+
return {"sha256": sha256_bytes(data), "size": len(data)}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def path_signatures(project: Path, path: Path) -> list[dict[str, str]]:
|
|
95
|
+
files = [path] if path.is_file() else sorted(item for item in path.rglob("*") if item.is_file())
|
|
96
|
+
return [{"path": relative(project, item), "sha256": file_signature(item)["sha256"]} for item in files]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def safe_relative(project: Path, raw: str, *, label: str, must_exist: bool = False) -> Path:
|
|
100
|
+
value = str(raw or "").strip()
|
|
101
|
+
if not value:
|
|
102
|
+
raise WebCaptureError(f"No {label} path is configured.")
|
|
103
|
+
relative = Path(value)
|
|
104
|
+
if relative.is_absolute() or ".." in relative.parts:
|
|
105
|
+
raise WebCaptureError(f"{label} must be a project-relative path: {raw}")
|
|
106
|
+
resolved = (project / relative).resolve()
|
|
107
|
+
try:
|
|
108
|
+
resolved.relative_to(project)
|
|
109
|
+
except ValueError as exc:
|
|
110
|
+
raise WebCaptureError(f"{label} escapes the project: {raw}") from exc
|
|
111
|
+
if must_exist and not resolved.exists():
|
|
112
|
+
raise WebCaptureError(f"Missing {label}: {relative}")
|
|
113
|
+
return resolved
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def relative(project: Path, path: Path) -> str:
|
|
117
|
+
return str(path.resolve().relative_to(project)).replace(os.sep, "/")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def validate_keys(mapping: dict[str, Any], allowed: set[str], *, label: str) -> None:
|
|
121
|
+
unknown = sorted(str(key) for key in mapping if key not in allowed)
|
|
122
|
+
if unknown:
|
|
123
|
+
raise WebCaptureError(f"Unknown {label} keys: {', '.join(unknown)}")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def mapping(value: Any, *, label: str) -> dict[str, Any]:
|
|
127
|
+
if value is None:
|
|
128
|
+
return {}
|
|
129
|
+
if not isinstance(value, dict):
|
|
130
|
+
raise WebCaptureError(f"{label} must be a mapping.")
|
|
131
|
+
return value
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def bounded_int(value: Any, *, label: str, minimum: int, maximum: int) -> int:
|
|
135
|
+
if isinstance(value, bool) or not isinstance(value, int) or not minimum <= value <= maximum:
|
|
136
|
+
raise WebCaptureError(f"{label} must be an integer between {minimum} and {maximum}.")
|
|
137
|
+
return value
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def validate_selector(value: Any, *, label: str) -> str:
|
|
141
|
+
selector = str(value or "").strip()
|
|
142
|
+
if not selector or len(selector) > 500 or any(character in selector for character in "\r\n\0"):
|
|
143
|
+
raise WebCaptureError(f"{label} must be a non-empty CSS selector of at most 500 characters.")
|
|
144
|
+
return selector
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def validate_path(value: Any) -> str:
|
|
148
|
+
path = str(value or "").strip()
|
|
149
|
+
parsed = urlsplit(path)
|
|
150
|
+
if not path.startswith("/") or path.startswith("//") or parsed.scheme or parsed.netloc or parsed.fragment or any(ord(char) < 32 for char in path):
|
|
151
|
+
raise WebCaptureError("Capture path must be a local absolute URL path without a scheme, host, fragment, or control characters.")
|
|
152
|
+
return path
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def normalize_viewport(raw: Any) -> dict[str, Any]:
|
|
156
|
+
viewport = {**DEFAULT_VIEWPORT, **mapping(raw, label="viewport")}
|
|
157
|
+
validate_keys(viewport, {"width", "height", "device_scale_factor"}, label="viewport")
|
|
158
|
+
width = bounded_int(viewport["width"], label="viewport width", minimum=320, maximum=4096)
|
|
159
|
+
height = bounded_int(viewport["height"], label="viewport height", minimum=240, maximum=4096)
|
|
160
|
+
scale = viewport["device_scale_factor"]
|
|
161
|
+
if scale not in {1, 2}:
|
|
162
|
+
raise WebCaptureError("viewport device_scale_factor must be 1 or 2.")
|
|
163
|
+
return {"width": width, "height": height, "device_scale_factor": scale}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def normalize_capture(raw: Any) -> dict[str, Any]:
|
|
167
|
+
capture = mapping(raw, label="capture")
|
|
168
|
+
validate_keys(capture, {"full_page", "selector", "clip", "padding"}, label="capture")
|
|
169
|
+
result: dict[str, Any] = {"full_page": bool(capture.get("full_page", False))}
|
|
170
|
+
if capture.get("selector"):
|
|
171
|
+
result["selector"] = validate_selector(capture["selector"], label="capture selector")
|
|
172
|
+
if capture.get("clip"):
|
|
173
|
+
clip = mapping(capture["clip"], label="capture clip")
|
|
174
|
+
validate_keys(clip, {"selector", "padding"}, label="capture clip")
|
|
175
|
+
result["clip"] = {
|
|
176
|
+
"selector": validate_selector(clip.get("selector"), label="capture clip selector"),
|
|
177
|
+
"padding": bounded_int(clip.get("padding", 0), label="capture clip padding", minimum=0, maximum=500),
|
|
178
|
+
}
|
|
179
|
+
result["padding"] = bounded_int(capture.get("padding", 0), label="capture padding", minimum=0, maximum=500)
|
|
180
|
+
modes = int(result["full_page"]) + int("selector" in result) + int("clip" in result)
|
|
181
|
+
if modes > 1:
|
|
182
|
+
raise WebCaptureError("capture full_page, selector, and clip are mutually exclusive.")
|
|
183
|
+
return result
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def normalize_theme(raw: Any) -> dict[str, Any]:
|
|
187
|
+
theme = mapping(raw, label="theme")
|
|
188
|
+
validate_keys(theme, {"setting", "color_scheme", "expect"}, label="theme")
|
|
189
|
+
setting = str(theme.get("setting") or "").strip()
|
|
190
|
+
if len(setting) > 80 or any(ord(char) < 32 for char in setting):
|
|
191
|
+
raise WebCaptureError("theme setting is invalid.")
|
|
192
|
+
color_scheme = str(theme.get("color_scheme") or ("dark" if setting == "dark" else "light"))
|
|
193
|
+
if color_scheme not in {"light", "dark", "no-preference"}:
|
|
194
|
+
raise WebCaptureError("theme color_scheme must be light, dark, or no-preference.")
|
|
195
|
+
result: dict[str, Any] = {"setting": setting, "color_scheme": color_scheme, "expect": None}
|
|
196
|
+
if theme.get("expect"):
|
|
197
|
+
expect = mapping(theme["expect"], label="theme expect")
|
|
198
|
+
validate_keys(expect, {"selector", "attribute", "equals"}, label="theme expect")
|
|
199
|
+
attribute = str(expect.get("attribute") or "").strip()
|
|
200
|
+
equals = str(expect.get("equals") or "")
|
|
201
|
+
if not re.fullmatch(r"[A-Za-z_:][-A-Za-z0-9_:.]*", attribute):
|
|
202
|
+
raise WebCaptureError("theme expect attribute is invalid.")
|
|
203
|
+
result["expect"] = {
|
|
204
|
+
"selector": validate_selector(expect.get("selector"), label="theme expect selector"),
|
|
205
|
+
"attribute": attribute,
|
|
206
|
+
"equals": equals,
|
|
207
|
+
}
|
|
208
|
+
return result
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def normalize_waits(raw: Any) -> dict[str, Any]:
|
|
212
|
+
waits = mapping(raw, label="waits")
|
|
213
|
+
validate_keys(waits, {"wait_until", "timeout_ms", "delay_ms", "fonts", "images", "selectors"}, label="waits")
|
|
214
|
+
wait_until = str(waits.get("wait_until") or "load")
|
|
215
|
+
if wait_until not in {"load", "domcontentloaded", "commit"}:
|
|
216
|
+
raise WebCaptureError("waits wait_until must be load, domcontentloaded, or commit.")
|
|
217
|
+
selectors = []
|
|
218
|
+
for index, raw_selector in enumerate(waits.get("selectors") or []):
|
|
219
|
+
item = {"selector": raw_selector} if isinstance(raw_selector, str) else mapping(raw_selector, label=f"wait selector {index}")
|
|
220
|
+
validate_keys(item, {"selector", "state"}, label=f"wait selector {index}")
|
|
221
|
+
state = str(item.get("state") or "visible")
|
|
222
|
+
if state not in {"attached", "detached", "visible", "hidden"}:
|
|
223
|
+
raise WebCaptureError(f"Invalid wait selector state: {state}")
|
|
224
|
+
selectors.append({"selector": validate_selector(item.get("selector"), label=f"wait selector {index}"), "state": state})
|
|
225
|
+
if len(selectors) > 100:
|
|
226
|
+
raise WebCaptureError("At most 100 wait selectors are allowed.")
|
|
227
|
+
return {
|
|
228
|
+
"wait_until": wait_until,
|
|
229
|
+
"timeout_ms": bounded_int(waits.get("timeout_ms", 30000), label="wait timeout", minimum=1000, maximum=120000),
|
|
230
|
+
"delay_ms": bounded_int(waits.get("delay_ms", 0), label="wait delay", minimum=0, maximum=10000),
|
|
231
|
+
"fonts": waits.get("fonts", True) is not False,
|
|
232
|
+
"images": waits.get("images", True) is not False,
|
|
233
|
+
"selectors": selectors,
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def normalize_annotations(raw: Any) -> list[dict[str, Any]]:
|
|
238
|
+
if raw is None:
|
|
239
|
+
return []
|
|
240
|
+
if not isinstance(raw, list) or len(raw) > 100:
|
|
241
|
+
raise WebCaptureError("annotations must be a list of at most 100 items.")
|
|
242
|
+
annotations = []
|
|
243
|
+
for index, value in enumerate(raw):
|
|
244
|
+
item = mapping(value, label=f"annotation {index}")
|
|
245
|
+
validate_keys(item, {"id", "selector", "kind", "text", "padding", "color", "stroke_width", "position", "offset", "strict", "nth", "required"}, label=f"annotation {index}")
|
|
246
|
+
identifier = str(item.get("id") or f"annotation-{index + 1}")
|
|
247
|
+
if not CAPTURE_ID_RE.fullmatch(identifier):
|
|
248
|
+
raise WebCaptureError(f"Invalid annotation id: {identifier}")
|
|
249
|
+
kind = str(item.get("kind") or "outline")
|
|
250
|
+
if kind not in {"outline", "label", "arrow", "spotlight"}:
|
|
251
|
+
raise WebCaptureError(f"Invalid annotation kind: {kind}")
|
|
252
|
+
color = str(item.get("color") or "#b42318")
|
|
253
|
+
if not re.fullmatch(r"#[0-9A-Fa-f]{6}", color):
|
|
254
|
+
raise WebCaptureError(f"Annotation color must be #RRGGBB: {color}")
|
|
255
|
+
position = str(item.get("position") or "top-right")
|
|
256
|
+
if position not in {"top-left", "top-right", "bottom-left", "bottom-right", "left", "right"}:
|
|
257
|
+
raise WebCaptureError(f"Invalid annotation position: {position}")
|
|
258
|
+
offset = item.get("offset") or [0, 0]
|
|
259
|
+
if not isinstance(offset, list) or len(offset) != 2 or any(isinstance(part, bool) or not isinstance(part, (int, float)) or abs(part) > 2000 for part in offset):
|
|
260
|
+
raise WebCaptureError(f"Annotation offset must contain two bounded numbers: {identifier}")
|
|
261
|
+
annotations.append({
|
|
262
|
+
"id": identifier,
|
|
263
|
+
"selector": validate_selector(item.get("selector"), label=f"annotation {identifier} selector"),
|
|
264
|
+
"kind": kind,
|
|
265
|
+
"text": str(item.get("text") or ""),
|
|
266
|
+
"padding": bounded_int(item.get("padding", 6), label=f"annotation {identifier} padding", minimum=0, maximum=500),
|
|
267
|
+
"color": color.lower(),
|
|
268
|
+
"stroke_width": bounded_int(item.get("stroke_width", 3), label=f"annotation {identifier} stroke_width", minimum=1, maximum=20),
|
|
269
|
+
"position": position,
|
|
270
|
+
"offset": [float(offset[0]), float(offset[1])],
|
|
271
|
+
"strict": item.get("strict", True) is not False,
|
|
272
|
+
"nth": bounded_int(item.get("nth", 0), label=f"annotation {identifier} nth", minimum=0, maximum=1000),
|
|
273
|
+
"required": item.get("required", True) is not False,
|
|
274
|
+
})
|
|
275
|
+
return annotations
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def read_recipe(project: Path, source: Path) -> dict[str, Any]:
|
|
279
|
+
try:
|
|
280
|
+
data = yaml.safe_load(source.read_text(encoding="utf-8"))
|
|
281
|
+
except yaml.YAMLError as exc:
|
|
282
|
+
raise WebCaptureError(f"Invalid capture recipe YAML in {relative(project, source)}: {exc}") from exc
|
|
283
|
+
if not isinstance(data, dict):
|
|
284
|
+
raise WebCaptureError(f"Capture recipe must be a YAML mapping: {relative(project, source)}")
|
|
285
|
+
validate_keys(data, {"version", "path", "viewport", "capture", "theme", "waits", "annotations", "inputs", "locale"}, label="capture recipe")
|
|
286
|
+
if data.get("version") != 1:
|
|
287
|
+
raise WebCaptureError(f"Unsupported capture recipe version in {relative(project, source)}")
|
|
288
|
+
inputs = data.get("inputs") or []
|
|
289
|
+
if not isinstance(inputs, list):
|
|
290
|
+
raise WebCaptureError("Capture recipe inputs must be a list.")
|
|
291
|
+
normalized_inputs = [relative(project, safe_relative(project, str(item), label="capture input", must_exist=True)) for item in inputs]
|
|
292
|
+
locale = str(data.get("locale") or "en-US")
|
|
293
|
+
if not re.fullmatch(r"[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*", locale):
|
|
294
|
+
raise WebCaptureError(f"Invalid capture locale: {locale}")
|
|
295
|
+
return {
|
|
296
|
+
"version": 1,
|
|
297
|
+
"path": validate_path(data.get("path")),
|
|
298
|
+
"viewport": normalize_viewport(data.get("viewport")),
|
|
299
|
+
"capture": normalize_capture(data.get("capture")),
|
|
300
|
+
"theme": normalize_theme(data.get("theme")),
|
|
301
|
+
"waits": normalize_waits(data.get("waits")),
|
|
302
|
+
"annotations": normalize_annotations(data.get("annotations")),
|
|
303
|
+
"inputs": normalized_inputs,
|
|
304
|
+
"locale": locale,
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def output_paths(source: Path) -> tuple[Path, Path, Path]:
|
|
309
|
+
if not source.name.endswith(SOURCE_SUFFIXES):
|
|
310
|
+
raise WebCaptureError(f"Capture source must end with .capture.yml or .capture.yaml: {source}")
|
|
311
|
+
return source.with_suffix(".png"), source.with_suffix(".svg"), source.with_suffix(".edited.svg")
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def discover(project: Path) -> list[dict[str, Any]]:
|
|
315
|
+
assets = project / "assets"
|
|
316
|
+
records = []
|
|
317
|
+
sources = sorted({source for suffix in SOURCE_SUFFIXES for source in assets.rglob(f"*{suffix}")}) if assets.is_dir() else []
|
|
318
|
+
for source in sources:
|
|
319
|
+
if any(part.startswith(".") for part in source.relative_to(project).parts):
|
|
320
|
+
continue
|
|
321
|
+
recipe = read_recipe(project, source)
|
|
322
|
+
png, svg, edited = output_paths(source)
|
|
323
|
+
records.append({"source": source, "source_path": relative(project, source), "recipe": recipe, "png": png, "svg": svg, "edited": edited})
|
|
324
|
+
return records
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def selected_records(project: Path, source: str = "") -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
328
|
+
records = discover(project)
|
|
329
|
+
if not source:
|
|
330
|
+
return records, records
|
|
331
|
+
if not SAFE_SOURCE_RE.fullmatch(source) or source.startswith("/") or ".." in Path(source).parts:
|
|
332
|
+
raise WebCaptureError("Selected capture source must be a safe project-relative *.capture.yml or *.capture.yaml path.")
|
|
333
|
+
selected = safe_relative(project, source, label="selected capture source", must_exist=True)
|
|
334
|
+
matches = [record for record in records if record["source"] == selected]
|
|
335
|
+
if not matches:
|
|
336
|
+
raise WebCaptureError(f"Selected file is not a capture recipe under assets/: {source}")
|
|
337
|
+
return matches, records
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def load_lock(project: Path) -> dict[str, Any]:
|
|
341
|
+
path = project / LOCK_PATH
|
|
342
|
+
if not path.is_file():
|
|
343
|
+
return {"version": 1, "records": {}}
|
|
344
|
+
try:
|
|
345
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
346
|
+
except json.JSONDecodeError as exc:
|
|
347
|
+
raise WebCaptureError(f"Invalid web capture lock file: {exc}") from exc
|
|
348
|
+
if not isinstance(data, dict) or data.get("version") != 1 or not isinstance(data.get("records"), dict):
|
|
349
|
+
raise WebCaptureError("Invalid web-captures.lock.json structure.")
|
|
350
|
+
return data
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def write_lock(project: Path, lock: dict[str, Any]) -> None:
|
|
354
|
+
path = project / LOCK_PATH
|
|
355
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
356
|
+
lock["version"] = 1
|
|
357
|
+
descriptor, raw_path = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
|
|
358
|
+
temporary = Path(raw_path)
|
|
359
|
+
try:
|
|
360
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
|
361
|
+
stream.write(json_dump(lock))
|
|
362
|
+
os.replace(temporary, path)
|
|
363
|
+
finally:
|
|
364
|
+
temporary.unlink(missing_ok=True)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def inspect_image(image: str) -> dict[str, str]:
|
|
368
|
+
configured_id = os.environ.get("UNALTRAWEB_CAPTURE_IMAGE_ID", "").strip()
|
|
369
|
+
configured_digest = os.environ.get("UNALTRAWEB_CAPTURE_IMAGE_DIGEST", "").strip()
|
|
370
|
+
if configured_id or configured_digest:
|
|
371
|
+
return {"available": "true", "id": configured_id, "digest": configured_digest}
|
|
372
|
+
if not shutil.which("docker"):
|
|
373
|
+
return {"available": "false", "id": "", "digest": ""}
|
|
374
|
+
completed = subprocess.run(["docker", "image", "inspect", image, "--format", '{{.Id}}|{{join .RepoDigests ","}}'], text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, check=False)
|
|
375
|
+
if completed.returncode != 0:
|
|
376
|
+
return {"available": "false", "id": "", "digest": ""}
|
|
377
|
+
image_id, _, digest = completed.stdout.strip().partition("|")
|
|
378
|
+
return {"available": "true", "id": image_id, "digest": digest}
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def selected_image() -> str:
|
|
382
|
+
return os.environ.get("WEB_CAPTURE_IMAGE", "").strip() or DEFAULT_IMAGE
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def factory_root() -> Path:
|
|
386
|
+
configured = os.environ.get("UNALTRAWEB_FACTORY_ROOT", "").strip()
|
|
387
|
+
return Path(configured).resolve() if configured else Path(__file__).resolve().parents[2]
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def renderer_files() -> list[Path]:
|
|
391
|
+
root = factory_root()
|
|
392
|
+
return [
|
|
393
|
+
root / "scripts/web_captures/render.py",
|
|
394
|
+
root / "scripts/web_captures/capture.mjs",
|
|
395
|
+
root / "scripts/web_captures/Dockerfile",
|
|
396
|
+
root / "scripts/web_captures/package.json",
|
|
397
|
+
root / "scripts/web_captures/package-lock.json",
|
|
398
|
+
root / "_config.yml",
|
|
399
|
+
root / "_plugins",
|
|
400
|
+
root / "_layouts",
|
|
401
|
+
root / "_includes",
|
|
402
|
+
root / "_sass",
|
|
403
|
+
root / "assets/js",
|
|
404
|
+
]
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def fingerprint(project: Path, record: dict[str, Any], saved: dict[str, Any] | None = None) -> tuple[str, list[dict[str, str]], dict[str, str]]:
|
|
408
|
+
dependencies = [record["source"], *[safe_relative(project, item, label="capture input", must_exist=True) for item in record["recipe"]["inputs"]]]
|
|
409
|
+
signatures = [signature for path in dependencies for signature in path_signatures(project, path)]
|
|
410
|
+
root = factory_root()
|
|
411
|
+
renderer = [
|
|
412
|
+
{"path": str(item.relative_to(root)).replace(os.sep, "/"), "sha256": file_signature(item)["sha256"]}
|
|
413
|
+
for path in renderer_files() if path.exists()
|
|
414
|
+
for item in ([path] if path.is_file() else sorted(candidate for candidate in path.rglob("*") if candidate.is_file()))
|
|
415
|
+
]
|
|
416
|
+
image = selected_image()
|
|
417
|
+
local_identity = inspect_image(image)
|
|
418
|
+
saved_identity = (saved or {}).get("image_identity") if isinstance((saved or {}).get("image_identity"), dict) else {}
|
|
419
|
+
identity = local_identity if local_identity["available"] == "true" else saved_identity
|
|
420
|
+
payload = {"recipe": record["recipe"], "dependencies": signatures, "renderer": renderer, "image": image, "image_identity": identity}
|
|
421
|
+
return sha256_bytes(canonical_json(payload)), signatures, {"image": image, **identity}
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def signatures_match(path: Path, saved: Any) -> bool:
|
|
425
|
+
return isinstance(saved, dict) and path.is_file() and saved.get("sha256") == file_signature(path)["sha256"] and saved.get("size") == path.stat().st_size
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def svg_metadata(path: Path) -> dict[str, Any] | None:
|
|
429
|
+
if not path.is_file():
|
|
430
|
+
return None
|
|
431
|
+
text = path.read_text(encoding="utf-8")
|
|
432
|
+
match = SVG_METADATA_RE.search(text)
|
|
433
|
+
if not match:
|
|
434
|
+
return None
|
|
435
|
+
try:
|
|
436
|
+
value = json.loads(html.unescape(match.group(1)))
|
|
437
|
+
except json.JSONDecodeError:
|
|
438
|
+
return None
|
|
439
|
+
return value if isinstance(value, dict) else None
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def validate_svg_safety(path: Path) -> None:
|
|
443
|
+
text = path.read_text(encoding="utf-8")
|
|
444
|
+
lowered = text.lower()
|
|
445
|
+
if "<!doctype" in lowered or "<!entity" in lowered:
|
|
446
|
+
raise WebCaptureError(f"Unsafe executable content in capture SVG: {path}")
|
|
447
|
+
try:
|
|
448
|
+
root = ET.fromstring(text)
|
|
449
|
+
except ET.ParseError as exc:
|
|
450
|
+
raise WebCaptureError(f"Invalid capture SVG XML: {path}: {exc}") from exc
|
|
451
|
+
for element in root.iter():
|
|
452
|
+
tag = element.tag.rsplit("}", 1)[-1].lower()
|
|
453
|
+
if tag not in SVG_ALLOWED_ELEMENTS:
|
|
454
|
+
raise WebCaptureError(f"Unsupported element in capture SVG: {path}: {tag}")
|
|
455
|
+
for raw_name, raw_value in element.attrib.items():
|
|
456
|
+
name = raw_name.rsplit("}", 1)[-1].lower()
|
|
457
|
+
value = raw_value.strip()
|
|
458
|
+
lowered_value = value.lower()
|
|
459
|
+
if name not in SVG_ALLOWED_ATTRIBUTES:
|
|
460
|
+
raise WebCaptureError(f"Unsupported attribute in capture SVG: {path}: {name}")
|
|
461
|
+
if "@import" in lowered_value or "javascript:" in lowered_value or "expression(" in lowered_value:
|
|
462
|
+
raise WebCaptureError(f"Unsafe attribute in capture SVG: {path}: {name}")
|
|
463
|
+
if name == "href" and not (value.startswith("data:image/png;base64,") or value.startswith("#")):
|
|
464
|
+
raise WebCaptureError(f"External reference in capture SVG: {path}")
|
|
465
|
+
without_local_urls = re.sub(r"url\(\s*['\"]?#[A-Za-z_][-:.A-Za-z0-9_]*['\"]?\s*\)", "", lowered_value)
|
|
466
|
+
if "url(" in without_local_urls:
|
|
467
|
+
raise WebCaptureError(f"External CSS reference in capture SVG: {path}")
|
|
468
|
+
if name == "style":
|
|
469
|
+
declarations = [part.strip() for part in value.split(";") if part.strip()]
|
|
470
|
+
for declaration in declarations:
|
|
471
|
+
property_name, separator, _ = declaration.partition(":")
|
|
472
|
+
if not separator or property_name.strip().lower() not in SVG_ALLOWED_STYLE_PROPERTIES:
|
|
473
|
+
raise WebCaptureError(f"Unsupported style in capture SVG: {path}: {property_name.strip()}")
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def edited_state(record: dict[str, Any], fingerprint_value: str = "") -> dict[str, Any]:
|
|
477
|
+
edited = record["edited"]
|
|
478
|
+
if not edited.is_file():
|
|
479
|
+
return {"status": "none", "path": ""}
|
|
480
|
+
validate_svg_safety(edited)
|
|
481
|
+
metadata = svg_metadata(edited)
|
|
482
|
+
png_hash = file_signature(record["png"])["sha256"] if record["png"].is_file() else ""
|
|
483
|
+
if not metadata or not metadata.get("png_sha256") or not metadata.get("fingerprint"):
|
|
484
|
+
return {"status": "invalid", "path": str(edited)}
|
|
485
|
+
current = metadata["png_sha256"] == png_hash and (not fingerprint_value or metadata["fingerprint"] == fingerprint_value)
|
|
486
|
+
return {"status": "current" if current else "stale", "path": str(edited), "png_sha256": metadata["png_sha256"], "fingerprint": metadata["fingerprint"]}
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def orphaned_artifacts(project: Path, records: list[dict[str, Any]]) -> list[str]:
|
|
490
|
+
expected = {relative(project, path) for record in records for path in [record["png"], record["svg"], record["edited"]]}
|
|
491
|
+
assets = project / "assets"
|
|
492
|
+
actual = {
|
|
493
|
+
relative(project, path)
|
|
494
|
+
for pattern in ["*.capture.png", "*.capture.svg", "*.capture.edited.svg"]
|
|
495
|
+
for path in (assets.rglob(pattern) if assets.is_dir() else [])
|
|
496
|
+
}
|
|
497
|
+
return sorted(actual - expected)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def status(project: Path, source: str = "") -> dict[str, Any]:
|
|
501
|
+
selected, all_records = selected_records(project, source)
|
|
502
|
+
lock = load_lock(project)
|
|
503
|
+
items = []
|
|
504
|
+
for record in selected:
|
|
505
|
+
saved = lock["records"].get(record["source_path"], {})
|
|
506
|
+
current_fingerprint, dependencies, image = fingerprint(project, record, saved)
|
|
507
|
+
png_valid = signatures_match(record["png"], saved.get("png"))
|
|
508
|
+
svg_valid = signatures_match(record["svg"], saved.get("svg"))
|
|
509
|
+
if record["svg"].is_file():
|
|
510
|
+
validate_svg_safety(record["svg"])
|
|
511
|
+
edited = edited_state(record, current_fingerprint)
|
|
512
|
+
current = bool(saved and saved.get("fingerprint") == current_fingerprint and png_valid and svg_valid and edited["status"] in {"none", "current"})
|
|
513
|
+
reason = "current"
|
|
514
|
+
if not saved:
|
|
515
|
+
reason = "not_rendered"
|
|
516
|
+
elif saved.get("fingerprint") != current_fingerprint:
|
|
517
|
+
reason = "source_or_environment_changed"
|
|
518
|
+
elif not record["png"].is_file():
|
|
519
|
+
reason = "png_missing"
|
|
520
|
+
elif not png_valid:
|
|
521
|
+
reason = "png_modified"
|
|
522
|
+
elif not record["svg"].is_file():
|
|
523
|
+
reason = "svg_missing"
|
|
524
|
+
elif not svg_valid:
|
|
525
|
+
reason = "svg_modified"
|
|
526
|
+
elif edited["status"] == "stale":
|
|
527
|
+
reason = "edited_override_stale"
|
|
528
|
+
elif edited["status"] == "invalid":
|
|
529
|
+
reason = "edited_override_invalid"
|
|
530
|
+
effective = record["edited"] if record["edited"].is_file() else record["svg"]
|
|
531
|
+
items.append({
|
|
532
|
+
"source": record["source_path"],
|
|
533
|
+
"path": record["recipe"]["path"],
|
|
534
|
+
"png": relative(project, record["png"]),
|
|
535
|
+
"svg": relative(project, record["svg"]),
|
|
536
|
+
"edited_svg": relative(project, record["edited"]),
|
|
537
|
+
"effective_svg": relative(project, effective),
|
|
538
|
+
"edited": {**edited, "path": relative(project, record["edited"]) if record["edited"].is_file() else ""},
|
|
539
|
+
"image": image,
|
|
540
|
+
"dependencies": dependencies,
|
|
541
|
+
"fingerprint": current_fingerprint,
|
|
542
|
+
"current": current,
|
|
543
|
+
"reason": reason,
|
|
544
|
+
})
|
|
545
|
+
known = {record["source_path"] for record in all_records}
|
|
546
|
+
orphaned = sorted(path for path in lock["records"] if path not in known)
|
|
547
|
+
orphaned_files = orphaned_artifacts(project, all_records)
|
|
548
|
+
return {
|
|
549
|
+
"project": str(project),
|
|
550
|
+
"lock": str(LOCK_PATH) if (project / LOCK_PATH).is_file() else "",
|
|
551
|
+
"captures": items,
|
|
552
|
+
"capture_count": len(items),
|
|
553
|
+
"current_count": sum(1 for item in items if item["current"]),
|
|
554
|
+
"orphaned_records": orphaned,
|
|
555
|
+
"orphaned_artifacts": orphaned_files,
|
|
556
|
+
"ok": all(item["current"] for item in items) and not orphaned and not orphaned_files,
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def validate_base_url(raw: str) -> str:
|
|
561
|
+
value = raw.strip().rstrip("/")
|
|
562
|
+
parsed = urlsplit(value)
|
|
563
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.username or parsed.password or parsed.query or parsed.fragment or parsed.path not in {"", "/"}:
|
|
564
|
+
raise WebCaptureError("Base URL must be an http(s) origin without credentials, path, query, or fragment.")
|
|
565
|
+
hostname = (parsed.hostname or "").lower()
|
|
566
|
+
service_host = os.environ.get("WEB_CAPTURE_SERVICE_HOST", "").strip().lower()
|
|
567
|
+
network = os.environ.get("WEB_CAPTURE_DOCKER_NETWORK", "").strip()
|
|
568
|
+
allowed = bool(service_host and hostname == service_host and network not in {"", "host", "none"})
|
|
569
|
+
if not allowed:
|
|
570
|
+
raise WebCaptureError("Base URL must use the declared service host on an isolated Docker network.")
|
|
571
|
+
if not internal_network(network):
|
|
572
|
+
raise WebCaptureError(f"Browser capture requires an internal Docker network: {network}")
|
|
573
|
+
return f"{parsed.scheme}://{parsed.netloc}"
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def internal_network(network: str) -> bool:
|
|
577
|
+
if not shutil.which("docker") or network in {"", "host", "none"}:
|
|
578
|
+
return False
|
|
579
|
+
completed = subprocess.run(
|
|
580
|
+
["docker", "network", "inspect", network, "--format", "{{.Internal}}"],
|
|
581
|
+
text=True,
|
|
582
|
+
stdout=subprocess.PIPE,
|
|
583
|
+
stderr=subprocess.DEVNULL,
|
|
584
|
+
check=False,
|
|
585
|
+
)
|
|
586
|
+
return completed.returncode == 0 and completed.stdout.strip().lower() == "true"
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def build_image(image: str) -> None:
|
|
590
|
+
available = inspect_image(image)["available"] == "true"
|
|
591
|
+
if available:
|
|
592
|
+
return
|
|
593
|
+
completed = subprocess.run(["docker", "pull", image], check=False)
|
|
594
|
+
if completed.returncode != 0:
|
|
595
|
+
raise WebCaptureError(f"Could not pull web capture image: {image}")
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def png_dimensions(path: Path) -> tuple[int, int]:
|
|
599
|
+
data = path.read_bytes()[:24]
|
|
600
|
+
if len(data) != 24 or data[:8] != b"\x89PNG\r\n\x1a\n":
|
|
601
|
+
raise WebCaptureError(f"Invalid PNG output: {path}")
|
|
602
|
+
return struct.unpack(">II", data[16:24])
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def xml(value: Any) -> str:
|
|
606
|
+
return html.escape(str(value), quote=True)
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def annotation_box(annotation: dict[str, Any], origin: dict[str, float], scale: float) -> dict[str, float]:
|
|
610
|
+
box = annotation["box"]
|
|
611
|
+
padding = annotation["padding"]
|
|
612
|
+
return {
|
|
613
|
+
"x": (box["x"] - origin["x"] - padding) * scale,
|
|
614
|
+
"y": (box["y"] - origin["y"] - padding) * scale,
|
|
615
|
+
"width": (box["width"] + padding * 2) * scale,
|
|
616
|
+
"height": (box["height"] + padding * 2) * scale,
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def label_position(box: dict[str, float], position: str, offset: list[float], scale: float) -> tuple[float, float]:
|
|
621
|
+
x = box["x"] + box["width"]
|
|
622
|
+
y = box["y"] - 12 * scale
|
|
623
|
+
if "left" in position:
|
|
624
|
+
x = box["x"]
|
|
625
|
+
if "bottom" in position:
|
|
626
|
+
y = box["y"] + box["height"] + 26 * scale
|
|
627
|
+
if position == "left":
|
|
628
|
+
x, y = box["x"] - 16 * scale, box["y"] + box["height"] / 2
|
|
629
|
+
if position == "right":
|
|
630
|
+
x, y = box["x"] + box["width"] + 16 * scale, box["y"] + box["height"] / 2
|
|
631
|
+
return x + offset[0] * scale, y + offset[1] * scale
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def svg_for(project: Path, record: dict[str, Any], png: Path, worker: dict[str, Any], fingerprint_value: str) -> str:
|
|
635
|
+
width, height = png_dimensions(png)
|
|
636
|
+
scale = float(worker["device_scale_factor"])
|
|
637
|
+
metadata = {
|
|
638
|
+
"schema": 1,
|
|
639
|
+
"source": record["source_path"],
|
|
640
|
+
"png_sha256": file_signature(png)["sha256"],
|
|
641
|
+
"fingerprint": fingerprint_value,
|
|
642
|
+
"path": record["recipe"]["path"],
|
|
643
|
+
"viewport": record["recipe"]["viewport"],
|
|
644
|
+
"browser_version": worker.get("browser_version", ""),
|
|
645
|
+
}
|
|
646
|
+
background = base64.b64encode(png.read_bytes()).decode("ascii")
|
|
647
|
+
highlights: list[str] = []
|
|
648
|
+
arrows: list[str] = []
|
|
649
|
+
labels: list[str] = []
|
|
650
|
+
notes: list[str] = []
|
|
651
|
+
for annotation in worker.get("annotations", []):
|
|
652
|
+
box = annotation_box(annotation, worker["origin"], scale)
|
|
653
|
+
if box["x"] + box["width"] <= 0 or box["y"] + box["height"] <= 0 or box["x"] >= width or box["y"] >= height:
|
|
654
|
+
if annotation.get("required", True):
|
|
655
|
+
raise WebCaptureError(f"Annotation is outside the captured area: {annotation['id']}")
|
|
656
|
+
continue
|
|
657
|
+
identifier = xml(annotation["id"])
|
|
658
|
+
selector = xml(annotation["selector"])
|
|
659
|
+
color = annotation["color"]
|
|
660
|
+
stroke = annotation["stroke_width"] * scale
|
|
661
|
+
rect = f'<rect x="{box["x"]:.2f}" y="{box["y"]:.2f}" width="{box["width"]:.2f}" height="{box["height"]:.2f}" rx="{6 * scale:.2f}" fill="none" stroke="{color}" stroke-width="{stroke:.2f}"/>'
|
|
662
|
+
if annotation["kind"] == "spotlight":
|
|
663
|
+
path = f'M0 0H{width}V{height}H0Z M{box["x"]:.2f} {box["y"]:.2f}h{box["width"]:.2f}v{box["height"]:.2f}h-{box["width"]:.2f}Z'
|
|
664
|
+
highlights.append(f'<g id="annotation-{identifier}" data-selector="{selector}"><path d="{path}" fill="#000000" fill-opacity="0.48" fill-rule="evenodd"/>{rect}</g>')
|
|
665
|
+
else:
|
|
666
|
+
highlights.append(f'<g id="annotation-{identifier}" data-selector="{selector}">{rect}</g>')
|
|
667
|
+
text = annotation.get("text", "")
|
|
668
|
+
if text:
|
|
669
|
+
lx, ly = label_position(box, annotation["position"], annotation["offset"], scale)
|
|
670
|
+
text_width = max(44, min(420, len(text) * 8 + 20)) * scale
|
|
671
|
+
text_height = 30 * scale
|
|
672
|
+
lx = max(4 * scale, min(lx, width - text_width - 4 * scale))
|
|
673
|
+
ly = max(text_height, min(ly, height - 4 * scale))
|
|
674
|
+
label = (
|
|
675
|
+
f'<g id="label-{identifier}" data-selector="{selector}">'
|
|
676
|
+
f'<rect x="{lx:.2f}" y="{ly - text_height:.2f}" width="{text_width:.2f}" height="{text_height:.2f}" rx="{5 * scale:.2f}" fill="{color}"/>'
|
|
677
|
+
f'<text x="{lx + 10 * scale:.2f}" y="{ly - 9 * scale:.2f}" fill="#ffffff" font-family="Arial, sans-serif" font-size="{14 * scale:.2f}" font-weight="600">{xml(text)}</text></g>'
|
|
678
|
+
)
|
|
679
|
+
labels.append(label)
|
|
680
|
+
if annotation["kind"] == "arrow":
|
|
681
|
+
cx = box["x"] + box["width"] / 2
|
|
682
|
+
cy = box["y"] + box["height"] / 2
|
|
683
|
+
arrows.append(f'<path id="arrow-{identifier}" d="M{lx + text_width / 2:.2f} {ly:.2f} L{cx:.2f} {cy:.2f}" fill="none" stroke="{color}" stroke-width="{stroke:.2f}" marker-end="url(#arrowhead)"/>')
|
|
684
|
+
if annotation["kind"] == "label" and not text:
|
|
685
|
+
notes.append(f'<!-- Empty label annotation: {identifier} -->')
|
|
686
|
+
metadata_text = xml(json.dumps(metadata, ensure_ascii=False, sort_keys=True, separators=(",", ":")))
|
|
687
|
+
return (
|
|
688
|
+
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
689
|
+
f'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="{width}" height="{height}" viewBox="0 0 {width} {height}">\n'
|
|
690
|
+
f' <metadata id="unaltraweb-capture">{metadata_text}</metadata>\n'
|
|
691
|
+
' <defs><marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto"><polygon points="0 0, 10 3.5, 0 7" fill="#b42318"/></marker></defs>\n'
|
|
692
|
+
f' <g id="background" inkscape:groupmode="layer" inkscape:label="Background"><image width="{width}" height="{height}" href="data:image/png;base64,{background}"/></g>\n'
|
|
693
|
+
f' <g id="highlights" inkscape:groupmode="layer" inkscape:label="Highlights">{"".join(highlights)}</g>\n'
|
|
694
|
+
f' <g id="arrows" inkscape:groupmode="layer" inkscape:label="Arrows">{"".join(arrows)}</g>\n'
|
|
695
|
+
f' <g id="labels" inkscape:groupmode="layer" inkscape:label="Labels">{"".join(labels)}</g>\n'
|
|
696
|
+
f' <g id="notes" inkscape:groupmode="layer" inkscape:label="Notes">{"".join(notes)}</g>\n'
|
|
697
|
+
'</svg>\n'
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def worker_config(record: dict[str, Any], base_url: str, png_path: str, result_path: str) -> dict[str, Any]:
|
|
702
|
+
recipe = record["recipe"]
|
|
703
|
+
return {
|
|
704
|
+
"url": base_url + recipe["path"],
|
|
705
|
+
"viewport": recipe["viewport"],
|
|
706
|
+
"capture": recipe["capture"],
|
|
707
|
+
"theme": recipe["theme"],
|
|
708
|
+
"waits": recipe["waits"],
|
|
709
|
+
"annotations": recipe["annotations"],
|
|
710
|
+
"locale": recipe["locale"],
|
|
711
|
+
"png_path": png_path,
|
|
712
|
+
"result_path": result_path,
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def run_worker(project: Path, image: str, config_path: Path) -> None:
|
|
717
|
+
network = os.environ.get("WEB_CAPTURE_DOCKER_NETWORK", "").strip()
|
|
718
|
+
if not internal_network(network):
|
|
719
|
+
raise WebCaptureError("Browser capture requires a named isolated Docker network.")
|
|
720
|
+
worker_values = {
|
|
721
|
+
"role": os.environ.get("UNALTRAWEB_WORKER_ROLE", "").strip(),
|
|
722
|
+
"project": os.environ.get("UNALTRAWEB_WORKER_PROJECT", "").strip(),
|
|
723
|
+
"token": os.environ.get("UNALTRAWEB_WORKER_TOKEN", "").strip(),
|
|
724
|
+
}
|
|
725
|
+
worker_args: list[str] = []
|
|
726
|
+
cidfile: Path | None = None
|
|
727
|
+
if any(worker_values.values()):
|
|
728
|
+
if worker_values["role"] != "web-capture" or not re.fullmatch(r"[0-9a-f]{16}", worker_values["project"]) or not re.fullmatch(r"[0-9a-f]{16}", worker_values["token"]):
|
|
729
|
+
raise WebCaptureError("Web capture worker identity is incomplete or invalid.")
|
|
730
|
+
runtime = project / "tmp/.unaltraweb/web-captures"
|
|
731
|
+
runtime.mkdir(parents=True, exist_ok=True)
|
|
732
|
+
cidfile = runtime / f"worker-{worker_values['token']}.cid"
|
|
733
|
+
cidfile.unlink(missing_ok=True)
|
|
734
|
+
worker_args = [
|
|
735
|
+
"--cidfile", str(cidfile),
|
|
736
|
+
"--label", "io.context.mcp-factory=unaltraweb",
|
|
737
|
+
"--label", f"io.context.mcp-role={worker_values['role']}",
|
|
738
|
+
"--label", f"io.context.mcp-project={worker_values['project']}",
|
|
739
|
+
"--label", f"io.context.mcp-worker-token={worker_values['token']}",
|
|
740
|
+
]
|
|
741
|
+
command = [
|
|
742
|
+
"docker", "run", "--rm", *worker_args, "--user", f"{os.getuid()}:{os.getgid()}", "--network", network, "--ipc=host",
|
|
743
|
+
"--read-only", "--cap-drop", "ALL", "--security-opt", "no-new-privileges", "--pids-limit", "256", "--cpus", "2", "--memory", "2g",
|
|
744
|
+
"--tmpfs", "/tmp:rw,noexec,nosuid,size=512m", "-e", "HOME=/tmp",
|
|
745
|
+
"--mount", docker_bind_mount(project, "/project"), "-w", "/project", image,
|
|
746
|
+
"/project/" + relative(project, config_path),
|
|
747
|
+
]
|
|
748
|
+
try:
|
|
749
|
+
completed = subprocess.run(command, check=False)
|
|
750
|
+
finally:
|
|
751
|
+
if cidfile is not None:
|
|
752
|
+
cidfile.unlink(missing_ok=True)
|
|
753
|
+
if completed.returncode != 0:
|
|
754
|
+
raise WebCaptureError(f"Browser capture failed with exit code {completed.returncode}.")
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def publish(record: dict[str, Any], png_temp: Path, svg_text: str, *, confirm_overwrite: bool, owned: bool) -> None:
|
|
758
|
+
png = record["png"]
|
|
759
|
+
svg = record["svg"]
|
|
760
|
+
if not owned and (png.exists() or svg.exists()) and not confirm_overwrite:
|
|
761
|
+
raise WebCaptureError(f"Refusing to replace unmanaged capture outputs for {record['source_path']}; rerun with --confirm-overwrite after review.")
|
|
762
|
+
png.parent.mkdir(parents=True, exist_ok=True)
|
|
763
|
+
token = secrets.token_hex(6)
|
|
764
|
+
svg_temp = svg.with_name(f".{svg.name}.{token}.tmp")
|
|
765
|
+
png_stage = png.with_name(f".{png.name}.{token}.tmp")
|
|
766
|
+
png_backup = png.with_name(f".{png.name}.{token}.backup")
|
|
767
|
+
svg_backup = svg.with_name(f".{svg.name}.{token}.backup")
|
|
768
|
+
shutil.copy2(png_temp, png_stage)
|
|
769
|
+
svg_temp.write_text(svg_text, encoding="utf-8")
|
|
770
|
+
try:
|
|
771
|
+
if png.exists():
|
|
772
|
+
os.replace(png, png_backup)
|
|
773
|
+
if svg.exists():
|
|
774
|
+
os.replace(svg, svg_backup)
|
|
775
|
+
os.replace(png_stage, png)
|
|
776
|
+
os.replace(svg_temp, svg)
|
|
777
|
+
except Exception:
|
|
778
|
+
png.unlink(missing_ok=True)
|
|
779
|
+
svg.unlink(missing_ok=True)
|
|
780
|
+
if png_backup.exists():
|
|
781
|
+
os.replace(png_backup, png)
|
|
782
|
+
if svg_backup.exists():
|
|
783
|
+
os.replace(svg_backup, svg)
|
|
784
|
+
raise
|
|
785
|
+
finally:
|
|
786
|
+
for path in [png_stage, svg_temp, png_backup, svg_backup]:
|
|
787
|
+
path.unlink(missing_ok=True)
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
@contextlib.contextmanager
|
|
791
|
+
def project_lock(project: Path):
|
|
792
|
+
root = project / "tmp" / "web-captures"
|
|
793
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
794
|
+
with (root / ".render.lock").open("w", encoding="utf-8") as stream:
|
|
795
|
+
fcntl.flock(stream.fileno(), fcntl.LOCK_EX)
|
|
796
|
+
yield
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def render(project: Path, base_url: str, source: str = "", confirm_overwrite: bool = False) -> dict[str, Any]:
|
|
800
|
+
base_url = validate_base_url(base_url)
|
|
801
|
+
selected, _ = selected_records(project, source)
|
|
802
|
+
image = selected_image()
|
|
803
|
+
build_image(image)
|
|
804
|
+
lock = load_lock(project)
|
|
805
|
+
rendered = []
|
|
806
|
+
with project_lock(project):
|
|
807
|
+
for record in selected:
|
|
808
|
+
saved = lock["records"].get(record["source_path"], {})
|
|
809
|
+
current_fingerprint, dependencies, image_state = fingerprint(project, record, saved)
|
|
810
|
+
with tempfile.TemporaryDirectory(prefix=f"{record['source'].stem}-", dir=project / "tmp" / "web-captures") as temporary_raw:
|
|
811
|
+
temporary = Path(temporary_raw)
|
|
812
|
+
png_temp = temporary / "capture.png"
|
|
813
|
+
result_temp = temporary / "result.json"
|
|
814
|
+
config_temp = temporary / "config.json"
|
|
815
|
+
config_temp.write_text(json_dump(worker_config(record, base_url, "/project/" + relative(project, png_temp), "/project/" + relative(project, result_temp))), encoding="utf-8")
|
|
816
|
+
run_worker(project, image, config_temp)
|
|
817
|
+
if not png_temp.is_file() or not result_temp.is_file():
|
|
818
|
+
raise WebCaptureError(f"Browser worker did not produce capture outputs for {record['source_path']}.")
|
|
819
|
+
worker = json.loads(result_temp.read_text(encoding="utf-8"))
|
|
820
|
+
svg_text = svg_for(project, record, png_temp, worker, current_fingerprint)
|
|
821
|
+
publish(record, png_temp, svg_text, confirm_overwrite=confirm_overwrite, owned=bool(saved))
|
|
822
|
+
identity = inspect_image(image)
|
|
823
|
+
lock["records"][record["source_path"]] = {
|
|
824
|
+
"fingerprint": current_fingerprint,
|
|
825
|
+
"dependencies": dependencies,
|
|
826
|
+
"image": image,
|
|
827
|
+
"image_identity": identity,
|
|
828
|
+
"png": {"path": relative(project, record["png"]), **file_signature(record["png"])},
|
|
829
|
+
"svg": {"path": relative(project, record["svg"]), **file_signature(record["svg"])},
|
|
830
|
+
"captured_at": utc_now(),
|
|
831
|
+
"final_path": urlsplit(worker.get("final_url", "")).path,
|
|
832
|
+
"browser_version": worker.get("browser_version", ""),
|
|
833
|
+
}
|
|
834
|
+
write_lock(project, lock)
|
|
835
|
+
rendered.append({"source": record["source_path"], "png": relative(project, record["png"]), "svg": relative(project, record["svg"]), "effective_svg": relative(project, record["edited"] if record["edited"].is_file() else record["svg"]), "annotations": len(worker.get("annotations", []))})
|
|
836
|
+
return {"project": str(project), "rendered": rendered, "rendered_count": len(rendered), "ok": True}
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def parser() -> argparse.ArgumentParser:
|
|
840
|
+
root = argparse.ArgumentParser(prog="unaltraweb-web-captures")
|
|
841
|
+
root.add_argument("command", choices=["status", "check", "render"])
|
|
842
|
+
root.add_argument("--project", default=".")
|
|
843
|
+
root.add_argument("--source", default="")
|
|
844
|
+
root.add_argument("--base-url", default="")
|
|
845
|
+
root.add_argument("--confirm-overwrite", action="store_true")
|
|
846
|
+
return root
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def main(argv: list[str] | None = None) -> int:
|
|
850
|
+
args = parser().parse_args(argv)
|
|
851
|
+
project = Path(args.project).expanduser().resolve()
|
|
852
|
+
try:
|
|
853
|
+
if args.command in {"status", "check"}:
|
|
854
|
+
result = status(project, source=args.source)
|
|
855
|
+
else:
|
|
856
|
+
if not args.base_url:
|
|
857
|
+
raise WebCaptureError("render requires --base-url for a trusted running preview.")
|
|
858
|
+
result = render(project, args.base_url, source=args.source, confirm_overwrite=args.confirm_overwrite)
|
|
859
|
+
print(json_dump(result), end="")
|
|
860
|
+
return 0 if args.command == "status" or result.get("ok") else 1
|
|
861
|
+
except WebCaptureError as exc:
|
|
862
|
+
print(json_dump({"project": str(project), "ok": False, "error": str(exc)}), end="")
|
|
863
|
+
return 2
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
if __name__ == "__main__":
|
|
867
|
+
raise SystemExit(main(sys.argv[1:]))
|