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,1780 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Build and locally publish unaltremanual PDFs from Jekyll sources."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import datetime as dt
|
|
8
|
+
import hashlib
|
|
9
|
+
import html
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import tempfile
|
|
17
|
+
import unicodedata
|
|
18
|
+
import xml.etree.ElementTree as ET
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
import yaml
|
|
24
|
+
except ImportError as exc: # pragma: no cover - supplied by the builder image
|
|
25
|
+
raise SystemExit("PyYAML is required to build an unaltremanual PDF.") from exc
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
SCRIPT_ROOT = Path(__file__).resolve().parent
|
|
29
|
+
DEFAULT_DOCKERFILE = SCRIPT_ROOT / "Dockerfile"
|
|
30
|
+
DEFAULT_TEMPLATE = SCRIPT_ROOT / "templates" / "manual.tex"
|
|
31
|
+
DEFAULT_BIBLIOGRAPHY_FILTER = SCRIPT_ROOT / "filters" / "bibliography.lua"
|
|
32
|
+
DEFAULT_CODE_BLOCK_FILTER = SCRIPT_ROOT / "filters" / "code-blocks.lua"
|
|
33
|
+
DEFAULT_FIGURE_FILTER = SCRIPT_ROOT / "filters" / "figure-captions.lua"
|
|
34
|
+
BIB_ENTRY_HEADER_RE = re.compile(r"(?im)^\s*@(?:[A-Za-z]+)\s*[({]\s*([^,\s]+)\s*,")
|
|
35
|
+
BIB_CUSTOM_URL_RE = re.compile(r'(?im)^\s*(?:manual_url|website)\s*=\s*(?:\{([^}\r\n]+)\}|"([^"\r\n]+)")\s*,?')
|
|
36
|
+
FRONT_MATTER_RE = re.compile(r"\A---[ \t]*\r?\n(.*?)^---[ \t]*(?:\r?\n|\Z)", re.MULTILINE | re.DOTALL)
|
|
37
|
+
CITE_RE = re.compile(r"{%\s*cite\s+([^%]+?)\s*%}")
|
|
38
|
+
INCLUDE_RE = re.compile(r"{%\s*include\s+([^%]+?)\s*%}")
|
|
39
|
+
LIQUID_RE = re.compile(r"({[{%].*?[}%]})", re.DOTALL)
|
|
40
|
+
INLINE_CODE_RE = re.compile(r"<code>(.*?)</code>", re.IGNORECASE | re.DOTALL)
|
|
41
|
+
FENCED_CODE_BLOCK_RE = re.compile(r"^[ \t]*(`{3,}|~{3,})[^\n]*\n.*?^[ \t]*\1[ \t]*$", re.MULTILINE | re.DOTALL)
|
|
42
|
+
MARKDOWN_INLINE_CODE_RE = re.compile(r"(`+)[^\n]*?\1")
|
|
43
|
+
DISPLAY_MATH_BLOCK_RE = re.compile(
|
|
44
|
+
r"^[ \t]*\$\$[ \t]*\r?\n(?P<body>.*?)^[ \t]*\$\$[ \t]*$",
|
|
45
|
+
re.MULTILINE | re.DOTALL,
|
|
46
|
+
)
|
|
47
|
+
BASEURL_RE = re.compile(r"\{\{\s*site\.baseurl\s*\}\}")
|
|
48
|
+
IMAGE_RE = re.compile(
|
|
49
|
+
r"!\[(?P<alt>[^\]]*)\]\((?P<path>\S+?)(?:\s+(?:\"(?P<double_title>[^\"]*)\"|'(?P<single_title>[^']*)'))?\)"
|
|
50
|
+
r"(?P<attrs>\{:[^}\n]*\})?"
|
|
51
|
+
)
|
|
52
|
+
TABLE_DIV_RE = re.compile(r'^::: table\s+["\'](.+?)["\']\s*\n(.*?)^:::\s*$', re.MULTILINE | re.DOTALL)
|
|
53
|
+
LISTING_DIV_RE = re.compile(
|
|
54
|
+
r'^:::\s*listing\s+"(?P<caption>[^"]+)"\s*\n(?P<body>.*?)^:::\s*$',
|
|
55
|
+
re.MULTILINE | re.DOTALL,
|
|
56
|
+
)
|
|
57
|
+
PIPE_TABLE_SEPARATOR_RE = re.compile(r"^\s*\|?\s*:?-{3,}:?(?:\s*\|\s*:?-{3,}:?)+\s*\|?\s*$")
|
|
58
|
+
TABLE_GUARD_AFTER_HEADING_RE = re.compile(
|
|
59
|
+
r"^(?P<heading>#{2,6}\s+[^\n]+)\n\n```\{=latex\}\n"
|
|
60
|
+
r"(?P<guard>\\(?:clearpage|Needspace\{(?P<baselines>\d+)\\baselineskip\}))\n```",
|
|
61
|
+
re.MULTILINE,
|
|
62
|
+
)
|
|
63
|
+
SUBFIGURES_DIV_RE = re.compile(
|
|
64
|
+
r'^:::\s*subfigures(?:\s+(?P<layout>[^\s"]+))?(?:\s+"(?P<caption>[^"]*)")?\s*\n'
|
|
65
|
+
r'(?P<body>.*?)^:::\s*$',
|
|
66
|
+
re.MULTILINE | re.DOTALL,
|
|
67
|
+
)
|
|
68
|
+
CALLOUT_BLOCK_RE = re.compile(r"^(?P<block>(?P<marks>>{2,})[^\n]*(?:\n(?P=marks)[^\n]*)*)", re.MULTILINE)
|
|
69
|
+
FIGURE_DIMENSION_ATTR_RE = re.compile(
|
|
70
|
+
r"(?:\A|\s)(?P<name>data-figure-(?:width|height)(?:-(?:web|pdf))?)\s*=\s*"
|
|
71
|
+
r"(?:\"(?P<double>[^\"]*)\"|'(?P<single>[^']*)'|(?P<bare>[^\s}]+))"
|
|
72
|
+
)
|
|
73
|
+
LANGUAGE_NAMES = {"ca": "catalan", "es": "spanish", "en": "english"}
|
|
74
|
+
LANGUAGE_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]*\Z")
|
|
75
|
+
RELEASE_SELECTOR_RE = re.compile(r"(?:latest|v[0-9]{4}\.(?:0[1-9]|1[0-2])(?:\.[1-9][0-9]*)?)\Z")
|
|
76
|
+
COMPUTATION_SUFFIXES = {".qmd", ".rmd", ".r", ".py", ".ipynb"}
|
|
77
|
+
DIAGRAM_SUFFIXES = {".mmd", ".mermaid", ".puml", ".plantuml", ".uml"}
|
|
78
|
+
GENERATED_MEDIA_SUFFIXES = {".gif", ".jpeg", ".jpg", ".pdf", ".png", ".svg", ".webp"}
|
|
79
|
+
VEGA_SOURCE_SUFFIXES = (".vl.json", ".vg.json")
|
|
80
|
+
VEGA_OUTPUT_SUFFIXES = {".svg", ".png", ".pdf"}
|
|
81
|
+
VEGA_MANIFEST_NAME = ".vegavisuals.yml"
|
|
82
|
+
VEGA_SAFE_NAME_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]*\Z")
|
|
83
|
+
REMOTE_PATH_RE = re.compile(r"^(?:[a-z][a-z0-9+.-]*:)?//", re.IGNORECASE)
|
|
84
|
+
VISUAL_LOCALIZATION_SUFFIXES = tuple(sorted({
|
|
85
|
+
".capture.edited.svg", ".capture.yaml", ".capture.yml", ".capture.svg",
|
|
86
|
+
".mermaid.edited.svg", ".mermaid.svg", ".plantuml.edited.svg", ".plantuml.svg",
|
|
87
|
+
".mmd.edited.svg", ".mmd.svg", ".puml.edited.svg", ".puml.svg", ".uml.edited.svg", ".uml.svg",
|
|
88
|
+
".vl.json", ".vg.json", ".edited.svg",
|
|
89
|
+
".qmd", ".rmd", ".ipynb", ".mermaid", ".plantuml", ".mmd", ".puml", ".uml", ".py", ".r",
|
|
90
|
+
".jpeg", ".tiff", ".webp", ".gif", ".jpg", ".png", ".svg", ".pdf",
|
|
91
|
+
}, key=len, reverse=True))
|
|
92
|
+
REPRODUCIBLE_BUILD_EPOCH = "0"
|
|
93
|
+
CALLOUT_STYLES = {
|
|
94
|
+
2: ("ManualCalloutInfo", "info"),
|
|
95
|
+
3: ("ManualCalloutExample", "example"),
|
|
96
|
+
4: ("ManualCalloutWarning", "warning"),
|
|
97
|
+
5: ("ManualCalloutObjectives", "objectives"),
|
|
98
|
+
6: ("ManualCalloutDanger", "danger"),
|
|
99
|
+
}
|
|
100
|
+
CALLOUT_LABELS = {
|
|
101
|
+
"ca": {
|
|
102
|
+
"info": "NOTA",
|
|
103
|
+
"example": "EXEMPLE",
|
|
104
|
+
"warning": "ADVERTÈNCIA",
|
|
105
|
+
"objectives": "OBJECTIUS D'APRENENTATGE",
|
|
106
|
+
"danger": "ATENCIÓ",
|
|
107
|
+
},
|
|
108
|
+
"es": {
|
|
109
|
+
"info": "NOTA",
|
|
110
|
+
"example": "EJEMPLO",
|
|
111
|
+
"warning": "ADVERTENCIA",
|
|
112
|
+
"objectives": "OBJETIVOS DE APRENDIZAJE",
|
|
113
|
+
"danger": "ATENCIÓN",
|
|
114
|
+
},
|
|
115
|
+
"en": {
|
|
116
|
+
"info": "NOTE",
|
|
117
|
+
"example": "EXAMPLE",
|
|
118
|
+
"warning": "WARNING",
|
|
119
|
+
"objectives": "LEARNING OBJECTIVES",
|
|
120
|
+
"danger": "CAUTION",
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
CAPTURE_SVG_ALLOWED_ELEMENTS = {
|
|
124
|
+
"svg", "g", "defs", "metadata", "title", "desc", "image", "rect", "path", "text", "tspan",
|
|
125
|
+
"marker", "polygon", "polyline", "line", "circle", "ellipse", "clippath", "mask", "lineargradient",
|
|
126
|
+
"radialgradient", "stop", "pattern", "use", "namedview", "grid", "page",
|
|
127
|
+
}
|
|
128
|
+
CAPTURE_SVG_ALLOWED_ATTRIBUTES = {
|
|
129
|
+
"id", "class", "version", "baseprofile", "x", "y", "x1", "y1", "x2", "y2", "cx", "cy", "r", "rx", "ry",
|
|
130
|
+
"width", "height", "viewbox", "preserveaspectratio", "d", "points", "transform", "fill", "stroke", "stroke-width",
|
|
131
|
+
"stroke-linecap", "stroke-linejoin", "stroke-miterlimit", "stroke-dasharray", "stroke-dashoffset", "opacity", "fill-opacity",
|
|
132
|
+
"stroke-opacity", "fill-rule", "clip-rule", "clip-path", "mask", "font-family", "font-size", "font-style", "font-weight",
|
|
133
|
+
"text-anchor", "dominant-baseline", "marker-start", "marker-mid", "marker-end", "markerwidth", "markerheight", "refx", "refy",
|
|
134
|
+
"orient", "offset", "stop-color", "stop-opacity", "patternunits", "patterncontentunits", "gradientunits", "gradienttransform",
|
|
135
|
+
"spreadmethod", "href", "style", "data-selector", "groupmode", "label", "space", "role", "aria-label", "pagecolor",
|
|
136
|
+
"bordercolor", "borderopacity", "objecttolerance", "gridtolerance", "guidetolerance", "showgrid", "showguides", "zoom",
|
|
137
|
+
"current-layer", "document-units", "pagecheckerboard", "deskcolor", "units", "originx", "originy", "spacingx", "spacingy",
|
|
138
|
+
}
|
|
139
|
+
CAPTURE_SVG_ALLOWED_STYLE_PROPERTIES = {
|
|
140
|
+
"fill", "stroke", "stroke-width", "stroke-linecap", "stroke-linejoin", "stroke-miterlimit", "stroke-dasharray",
|
|
141
|
+
"stroke-dashoffset", "opacity", "fill-opacity", "stroke-opacity", "fill-rule", "clip-rule", "clip-path", "mask",
|
|
142
|
+
"font-family", "font-size", "font-style", "font-weight", "text-anchor", "dominant-baseline", "marker-start", "marker-mid",
|
|
143
|
+
"marker-end", "stop-color", "stop-opacity", "display", "visibility",
|
|
144
|
+
}
|
|
145
|
+
METADATA_LABELS = {
|
|
146
|
+
"ca": {
|
|
147
|
+
"title": "Crèdits editorials",
|
|
148
|
+
"series": "Col·lecció",
|
|
149
|
+
"publisher": "Editorial",
|
|
150
|
+
"edition": "Edició",
|
|
151
|
+
"publication_date": "Data de publicació",
|
|
152
|
+
"release_channel": "Canal de publicació",
|
|
153
|
+
"release_version": "Versió publicada",
|
|
154
|
+
"subject": "Assignatura",
|
|
155
|
+
"teaching_guides": "Guies docents",
|
|
156
|
+
"academic_year": "Curs acadèmic",
|
|
157
|
+
"department": "Departament",
|
|
158
|
+
"faculty": "Facultat",
|
|
159
|
+
"institution": "Institució",
|
|
160
|
+
"location": "Localització",
|
|
161
|
+
"revision_date": "Data de revisió",
|
|
162
|
+
"instructors": "Autoria",
|
|
163
|
+
"identifier": "Identificador",
|
|
164
|
+
"license": "Llicència",
|
|
165
|
+
"source": "Edició web",
|
|
166
|
+
"rights": "Drets",
|
|
167
|
+
"references": "Referències",
|
|
168
|
+
"listing": "Codi",
|
|
169
|
+
"list_of_figures": "Índex de figures",
|
|
170
|
+
"list_of_tables": "Índex de taules",
|
|
171
|
+
"list_of_listings": "Índex de codis",
|
|
172
|
+
},
|
|
173
|
+
"es": {
|
|
174
|
+
"title": "Créditos editoriales",
|
|
175
|
+
"series": "Colección",
|
|
176
|
+
"publisher": "Editorial",
|
|
177
|
+
"edition": "Edición",
|
|
178
|
+
"publication_date": "Fecha de publicación",
|
|
179
|
+
"release_channel": "Canal de publicación",
|
|
180
|
+
"release_version": "Versión publicada",
|
|
181
|
+
"subject": "Asignatura",
|
|
182
|
+
"teaching_guides": "Guías docentes",
|
|
183
|
+
"academic_year": "Curso académico",
|
|
184
|
+
"department": "Departamento",
|
|
185
|
+
"faculty": "Facultad",
|
|
186
|
+
"institution": "Institución",
|
|
187
|
+
"location": "Localización",
|
|
188
|
+
"revision_date": "Fecha de revisión",
|
|
189
|
+
"instructors": "Autoría",
|
|
190
|
+
"identifier": "Identificador",
|
|
191
|
+
"license": "Licencia",
|
|
192
|
+
"source": "Edición web",
|
|
193
|
+
"rights": "Derechos",
|
|
194
|
+
"references": "Referencias",
|
|
195
|
+
"listing": "Código",
|
|
196
|
+
"list_of_figures": "Índice de figuras",
|
|
197
|
+
"list_of_tables": "Índice de tablas",
|
|
198
|
+
"list_of_listings": "Índice de códigos",
|
|
199
|
+
},
|
|
200
|
+
"en": {
|
|
201
|
+
"title": "Editorial credits",
|
|
202
|
+
"series": "Series",
|
|
203
|
+
"publisher": "Publisher",
|
|
204
|
+
"edition": "Edition",
|
|
205
|
+
"publication_date": "Publication date",
|
|
206
|
+
"release_channel": "Publication channel",
|
|
207
|
+
"release_version": "Published version",
|
|
208
|
+
"subject": "Course",
|
|
209
|
+
"teaching_guides": "Teaching guides",
|
|
210
|
+
"academic_year": "Academic year",
|
|
211
|
+
"department": "Department",
|
|
212
|
+
"faculty": "Faculty",
|
|
213
|
+
"institution": "Institution",
|
|
214
|
+
"location": "Location",
|
|
215
|
+
"revision_date": "Revision date",
|
|
216
|
+
"instructors": "Authors",
|
|
217
|
+
"identifier": "Identifier",
|
|
218
|
+
"license": "License",
|
|
219
|
+
"source": "Web edition",
|
|
220
|
+
"rights": "Rights",
|
|
221
|
+
"references": "References",
|
|
222
|
+
"listing": "Code example",
|
|
223
|
+
"list_of_figures": "List of figures",
|
|
224
|
+
"list_of_tables": "List of tables",
|
|
225
|
+
"list_of_listings": "List of code examples",
|
|
226
|
+
},
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class ManualPdfError(RuntimeError):
|
|
231
|
+
pass
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def release_metadata() -> dict[str, str]:
|
|
235
|
+
selector = os.environ.get("UNALTRAWEB_MANUAL_RELEASE_SELECTOR", "latest").strip()
|
|
236
|
+
if not RELEASE_SELECTOR_RE.fullmatch(selector):
|
|
237
|
+
raise ManualPdfError(
|
|
238
|
+
"UNALTRAWEB_MANUAL_RELEASE_SELECTOR must be exactly latest, vYYYY.MM, or vYYYY.MM.N."
|
|
239
|
+
)
|
|
240
|
+
return {
|
|
241
|
+
"release-selector": selector,
|
|
242
|
+
"release-channel": "latest" if selector == "latest" else "stable",
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class UniqueKeySafeLoader(yaml.SafeLoader):
|
|
247
|
+
def construct_mapping(self, node: yaml.nodes.MappingNode, deep: bool = False) -> dict[Any, Any]:
|
|
248
|
+
self.flatten_mapping(node)
|
|
249
|
+
mapping: dict[Any, Any] = {}
|
|
250
|
+
for key_node, value_node in node.value:
|
|
251
|
+
key = self.construct_object(key_node, deep=deep)
|
|
252
|
+
try:
|
|
253
|
+
duplicate = key in mapping
|
|
254
|
+
except TypeError as exc:
|
|
255
|
+
raise yaml.constructor.ConstructorError(
|
|
256
|
+
"while constructing a mapping",
|
|
257
|
+
node.start_mark,
|
|
258
|
+
"found an unhashable mapping key",
|
|
259
|
+
key_node.start_mark,
|
|
260
|
+
) from exc
|
|
261
|
+
if duplicate:
|
|
262
|
+
raise yaml.constructor.ConstructorError(
|
|
263
|
+
"while constructing a mapping",
|
|
264
|
+
node.start_mark,
|
|
265
|
+
f"found duplicate key: {key}",
|
|
266
|
+
key_node.start_mark,
|
|
267
|
+
)
|
|
268
|
+
mapping[key] = self.construct_object(value_node, deep=deep)
|
|
269
|
+
return mapping
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def read_yaml(path: Path) -> dict[str, Any]:
|
|
273
|
+
if not path.is_file():
|
|
274
|
+
raise ManualPdfError(f"Missing required YAML file: {path}")
|
|
275
|
+
parsed = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
276
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def read_source(path: Path) -> tuple[dict[str, Any], str]:
|
|
280
|
+
text = path.read_text(encoding="utf-8")
|
|
281
|
+
match = FRONT_MATTER_RE.match(text)
|
|
282
|
+
if not match:
|
|
283
|
+
return {}, text
|
|
284
|
+
parsed = yaml.safe_load(match.group(1))
|
|
285
|
+
return (parsed if isinstance(parsed, dict) else {}), text[match.end():]
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def nested(config: dict[str, Any], *keys: str) -> dict[str, Any]:
|
|
289
|
+
value: Any = config
|
|
290
|
+
for key in keys:
|
|
291
|
+
if not isinstance(value, dict):
|
|
292
|
+
return {}
|
|
293
|
+
value = value.get(key)
|
|
294
|
+
return value if isinstance(value, dict) else {}
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def safe_relative(project: Path, raw: str, *, label: str, must_exist: bool = False) -> Path:
|
|
298
|
+
value = str(raw or "").strip()
|
|
299
|
+
if not value:
|
|
300
|
+
raise ManualPdfError(f"No {label} path is configured.")
|
|
301
|
+
relative = Path(value)
|
|
302
|
+
if relative.is_absolute() or ".." in relative.parts:
|
|
303
|
+
raise ManualPdfError(f"{label} must be a project-relative path: {raw}")
|
|
304
|
+
resolved = (project / relative).resolve()
|
|
305
|
+
try:
|
|
306
|
+
resolved.relative_to(project)
|
|
307
|
+
except ValueError as exc:
|
|
308
|
+
raise ManualPdfError(f"{label} escapes the project: {raw}") from exc
|
|
309
|
+
if must_exist and not resolved.exists():
|
|
310
|
+
raise ManualPdfError(f"Missing {label}: {relative}")
|
|
311
|
+
return resolved
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def validate_language(value: str) -> str:
|
|
315
|
+
language = value.strip()
|
|
316
|
+
if not language or not LANGUAGE_RE.fullmatch(language):
|
|
317
|
+
raise ManualPdfError(f"Invalid PDF language: {value!r}")
|
|
318
|
+
return language
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def normalize_callout_language(value: Any) -> str:
|
|
322
|
+
normalized = str(value or "").lower().split("-", 1)[0]
|
|
323
|
+
return normalized or "en"
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def language_data(project: Path, config: dict[str, Any], lang: str) -> tuple[dict[str, Any], Path | None]:
|
|
327
|
+
data_root = safe_relative(project, str(config.get("data_dir") or "_data"), label="Jekyll data directory")
|
|
328
|
+
if not LANGUAGE_RE.fullmatch(lang):
|
|
329
|
+
return {}, None
|
|
330
|
+
for suffix in (".yml", ".yaml"):
|
|
331
|
+
path = data_root / "i18n" / f"{lang}{suffix}"
|
|
332
|
+
if not path.is_file():
|
|
333
|
+
continue
|
|
334
|
+
parsed = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
335
|
+
return (parsed if isinstance(parsed, dict) else {}), path
|
|
336
|
+
return {}, None
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def resolve_callout_labels(project: Path, config: dict[str, Any], language: str) -> tuple[dict[str, str], list[Path]]:
|
|
340
|
+
normalized_language = normalize_callout_language(language)
|
|
341
|
+
default_language = normalize_callout_language(config.get("default_lang") or config.get("lang") or "en")
|
|
342
|
+
localized_data, localized_path = language_data(project, config, normalized_language)
|
|
343
|
+
if default_language == normalized_language:
|
|
344
|
+
default_data, default_path = localized_data, localized_path
|
|
345
|
+
else:
|
|
346
|
+
default_data, default_path = language_data(project, config, default_language)
|
|
347
|
+
|
|
348
|
+
localized_callouts = localized_data.get("callouts")
|
|
349
|
+
default_callouts = default_data.get("callouts")
|
|
350
|
+
if isinstance(localized_callouts, dict):
|
|
351
|
+
configured = localized_callouts
|
|
352
|
+
elif isinstance(default_callouts, dict):
|
|
353
|
+
configured = default_callouts
|
|
354
|
+
else:
|
|
355
|
+
configured = {}
|
|
356
|
+
|
|
357
|
+
defaults = CALLOUT_LABELS.get(normalized_language) or CALLOUT_LABELS.get(default_language) or CALLOUT_LABELS["en"]
|
|
358
|
+
labels = dict(defaults)
|
|
359
|
+
labels.update({str(key): "" if value is None else str(value) for key, value in configured.items()})
|
|
360
|
+
dependencies = list(dict.fromkeys(path for path in (localized_path, default_path) if path is not None))
|
|
361
|
+
return labels, dependencies
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def language_list(config: dict[str, Any], pdf: dict[str, Any], requested: str = "") -> list[str]:
|
|
365
|
+
configured = pdf.get("languages")
|
|
366
|
+
values: list[str] = []
|
|
367
|
+
if isinstance(configured, list):
|
|
368
|
+
values = [validate_language(str(item)) for item in configured if str(item).strip()]
|
|
369
|
+
if not values:
|
|
370
|
+
values = [validate_language(str(config.get("default_lang") or config.get("lang") or "en"))]
|
|
371
|
+
if requested.strip():
|
|
372
|
+
language = validate_language(requested)
|
|
373
|
+
if language not in values:
|
|
374
|
+
raise ManualPdfError(f"Language '{language}' is not enabled in unaltraweb.manual.pdf.languages.")
|
|
375
|
+
return [language]
|
|
376
|
+
return values
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def render_path(template: str, lang: str) -> str:
|
|
380
|
+
try:
|
|
381
|
+
return template.format(lang=lang)
|
|
382
|
+
except (KeyError, ValueError) as exc:
|
|
383
|
+
raise ManualPdfError(f"Invalid language path template: {template}") from exc
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def artifact_paths(project: Path, config: dict[str, Any], lang: str) -> dict[str, Path]:
|
|
387
|
+
lang = validate_language(lang)
|
|
388
|
+
manual = nested(config, "unaltraweb", "manual")
|
|
389
|
+
pdf = manual.get("pdf") if isinstance(manual.get("pdf"), dict) else {}
|
|
390
|
+
build_dir_raw = str(pdf.get("build_dir") or "tmp/manual-pdf")
|
|
391
|
+
build_root_raw = str(Path(build_dir_raw) / lang)
|
|
392
|
+
build_root = safe_relative(project, build_root_raw, label="language PDF build directory")
|
|
393
|
+
public_pdf_raw = render_path(str(pdf.get("output") or "assets/pdf/manual-{lang}.pdf"), lang)
|
|
394
|
+
public_cover_raw = render_path(str(pdf.get("cover_output") or "assets/img/manual-cover-{lang}.png"), lang)
|
|
395
|
+
if Path(public_pdf_raw).suffix.lower() != ".pdf":
|
|
396
|
+
raise ManualPdfError("Published PDF path must use the .pdf extension.")
|
|
397
|
+
if Path(public_cover_raw).suffix.lower() != ".png":
|
|
398
|
+
raise ManualPdfError("Published cover path must use the .png extension.")
|
|
399
|
+
public_pdf = safe_relative(project, public_pdf_raw, label="published PDF")
|
|
400
|
+
public_cover = safe_relative(project, public_cover_raw, label="published cover")
|
|
401
|
+
if public_pdf == public_cover:
|
|
402
|
+
raise ManualPdfError("Published PDF and cover paths must be different.")
|
|
403
|
+
if Path(public_pdf_raw).name == Path(public_cover_raw).name:
|
|
404
|
+
raise ManualPdfError("Published PDF and cover filenames must be different.")
|
|
405
|
+
return {
|
|
406
|
+
"build_dir": build_root,
|
|
407
|
+
"pdf": build_root / Path(public_pdf_raw).name,
|
|
408
|
+
"cover": build_root / Path(public_cover_raw).name,
|
|
409
|
+
"source": build_root / "manual.md",
|
|
410
|
+
"metadata": build_root / "metadata.yml",
|
|
411
|
+
"bibliography": build_root / "bibliography.bib",
|
|
412
|
+
"manifest": build_root / "manifest.json",
|
|
413
|
+
"public_pdf": public_pdf,
|
|
414
|
+
"public_cover": public_cover,
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def artifact_plan(project: Path, config: dict[str, Any], languages: list[str]) -> list[tuple[str, dict[str, Path]]]:
|
|
419
|
+
plan = [(language, artifact_paths(project, config, language)) for language in languages]
|
|
420
|
+
destinations: dict[Path, tuple[str, str]] = {}
|
|
421
|
+
labels = {
|
|
422
|
+
"pdf": "generated PDF",
|
|
423
|
+
"cover": "generated cover",
|
|
424
|
+
"public_pdf": "published PDF",
|
|
425
|
+
"public_cover": "published cover",
|
|
426
|
+
}
|
|
427
|
+
for language, paths in plan:
|
|
428
|
+
for key, label in labels.items():
|
|
429
|
+
destination = paths[key]
|
|
430
|
+
previous = destinations.get(destination)
|
|
431
|
+
if previous is not None:
|
|
432
|
+
previous_language, previous_label = previous
|
|
433
|
+
relative = destination.relative_to(project).as_posix()
|
|
434
|
+
raise ManualPdfError(
|
|
435
|
+
f"Manual PDF destination collision: {previous_label} for '{previous_language}' and "
|
|
436
|
+
f"{label} for '{language}' both resolve to {relative}."
|
|
437
|
+
)
|
|
438
|
+
destinations[destination] = (language, label)
|
|
439
|
+
return plan
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def manual_sources(project: Path, config: dict[str, Any], lang: str) -> tuple[Path | None, list[tuple[Path, dict[str, Any], str]], str]:
|
|
443
|
+
manual = nested(config, "unaltraweb", "manual")
|
|
444
|
+
collection = str(manual.get("collection") or "chapters").strip()
|
|
445
|
+
collection_root = safe_relative(project, f"_{collection}", label="manual collection", must_exist=True)
|
|
446
|
+
default_lang = str(config.get("default_lang") or config.get("lang") or lang)
|
|
447
|
+
|
|
448
|
+
pages: list[tuple[Path, dict[str, Any], str]] = []
|
|
449
|
+
for path in sorted(collection_root.rglob("*")):
|
|
450
|
+
if path.suffix.lower() not in {".md", ".markdown"} or not path.is_file():
|
|
451
|
+
continue
|
|
452
|
+
front, body = read_source(path)
|
|
453
|
+
if front.get("pdf") is False:
|
|
454
|
+
continue
|
|
455
|
+
pages.append((path, front, body))
|
|
456
|
+
|
|
457
|
+
selected = [item for item in pages if str(item[1].get("lang") or default_lang) == lang]
|
|
458
|
+
if not selected:
|
|
459
|
+
raise ManualPdfError(f"No manual chapters found for language '{lang}'.")
|
|
460
|
+
|
|
461
|
+
def weight(item: tuple[Path, dict[str, Any], str]) -> tuple[float, str]:
|
|
462
|
+
raw = item[1].get("weight", 0)
|
|
463
|
+
try:
|
|
464
|
+
numeric = float(raw)
|
|
465
|
+
except (TypeError, ValueError):
|
|
466
|
+
numeric = 0
|
|
467
|
+
return numeric, str(item[0])
|
|
468
|
+
|
|
469
|
+
selected.sort(key=weight)
|
|
470
|
+
numbered = [item for item in selected if item[1].get("manual_numbered") is not False]
|
|
471
|
+
references = [item for item in selected if item[1].get("manual_numbered") is False]
|
|
472
|
+
|
|
473
|
+
home: Path | None = None
|
|
474
|
+
home_candidates: list[Path] = []
|
|
475
|
+
pages_root = project / "_pages"
|
|
476
|
+
if pages_root.is_dir():
|
|
477
|
+
for path in sorted(pages_root.rglob("*")):
|
|
478
|
+
if path.suffix.lower() not in {".md", ".markdown"} or not path.is_file():
|
|
479
|
+
continue
|
|
480
|
+
front, _ = read_source(path)
|
|
481
|
+
if front.get("layout") == "manual-home" and str(front.get("lang") or default_lang) == lang:
|
|
482
|
+
home_candidates.append(path)
|
|
483
|
+
if len(home_candidates) > 1:
|
|
484
|
+
choices = ", ".join(str(path.relative_to(project)) for path in home_candidates)
|
|
485
|
+
raise ManualPdfError(f"Multiple manual-home pages found for language '{lang}': {choices}")
|
|
486
|
+
if home_candidates:
|
|
487
|
+
home = home_candidates[0]
|
|
488
|
+
return home, numbered + references, lang
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def validate_capture_svg(path: Path) -> None:
|
|
492
|
+
text = path.read_text(encoding="utf-8")
|
|
493
|
+
lowered = text.lower()
|
|
494
|
+
if "<!doctype" in lowered or "<!entity" in lowered:
|
|
495
|
+
raise ManualPdfError(f"Unsafe web capture SVG: {path}")
|
|
496
|
+
try:
|
|
497
|
+
root = ET.fromstring(text)
|
|
498
|
+
except ET.ParseError as exc:
|
|
499
|
+
raise ManualPdfError(f"Invalid web capture SVG: {path}: {exc}") from exc
|
|
500
|
+
for element in root.iter():
|
|
501
|
+
tag = element.tag.rsplit("}", 1)[-1].lower()
|
|
502
|
+
if tag not in CAPTURE_SVG_ALLOWED_ELEMENTS:
|
|
503
|
+
raise ManualPdfError(f"Unsupported web capture SVG element in {path}: {tag}")
|
|
504
|
+
for raw_name, raw_value in element.attrib.items():
|
|
505
|
+
name = raw_name.rsplit("}", 1)[-1].lower()
|
|
506
|
+
value = raw_value.strip()
|
|
507
|
+
lowered_value = value.lower()
|
|
508
|
+
if name not in CAPTURE_SVG_ALLOWED_ATTRIBUTES:
|
|
509
|
+
raise ManualPdfError(f"Unsupported web capture SVG attribute in {path}: {name}")
|
|
510
|
+
if "@import" in lowered_value or "javascript:" in lowered_value or "expression(" in lowered_value:
|
|
511
|
+
raise ManualPdfError(f"Unsafe web capture SVG attribute in {path}: {name}")
|
|
512
|
+
if name == "href" and not (value.startswith("data:image/png;base64,") or value.startswith("#")):
|
|
513
|
+
raise ManualPdfError(f"External web capture SVG reference in {path}")
|
|
514
|
+
without_local_urls = re.sub(r"url\(\s*['\"]?#[A-Za-z_][-:.A-Za-z0-9_]*['\"]?\s*\)", "", lowered_value)
|
|
515
|
+
if "url(" in without_local_urls:
|
|
516
|
+
raise ManualPdfError(f"External CSS reference in web capture SVG: {path}")
|
|
517
|
+
if name == "style":
|
|
518
|
+
declarations = [part.strip() for part in value.split(";") if part.strip()]
|
|
519
|
+
for declaration in declarations:
|
|
520
|
+
property_name, separator, _ = declaration.partition(":")
|
|
521
|
+
if not separator or property_name.strip().lower() not in CAPTURE_SVG_ALLOWED_STYLE_PROPERTIES:
|
|
522
|
+
raise ManualPdfError(f"Unsupported CSS property in web capture SVG {path}: {property_name.strip()}")
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def commented_front_matter(path: Path, prefix: str) -> str:
|
|
526
|
+
marker = f"{prefix} ---"
|
|
527
|
+
active = False
|
|
528
|
+
lines: list[str] = []
|
|
529
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
530
|
+
if line.strip() == marker:
|
|
531
|
+
if active:
|
|
532
|
+
return "\n".join(lines)
|
|
533
|
+
active = True
|
|
534
|
+
continue
|
|
535
|
+
if active:
|
|
536
|
+
if not line.startswith(prefix):
|
|
537
|
+
break
|
|
538
|
+
value = line[len(prefix):]
|
|
539
|
+
lines.append(value[1:] if value.startswith(" ") else value)
|
|
540
|
+
return ""
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def computation_front_matter(path: Path) -> dict[str, Any]:
|
|
544
|
+
suffix = path.suffix.lower()
|
|
545
|
+
text = ""
|
|
546
|
+
if suffix in {".qmd", ".rmd"}:
|
|
547
|
+
match = FRONT_MATTER_RE.match(path.read_text(encoding="utf-8"))
|
|
548
|
+
text = match.group(1) if match else ""
|
|
549
|
+
elif suffix == ".r":
|
|
550
|
+
text = commented_front_matter(path, "#'")
|
|
551
|
+
elif suffix == ".py":
|
|
552
|
+
text = commented_front_matter(path, "#")
|
|
553
|
+
elif suffix == ".ipynb":
|
|
554
|
+
try:
|
|
555
|
+
notebook = json.loads(path.read_text(encoding="utf-8"))
|
|
556
|
+
except json.JSONDecodeError as exc:
|
|
557
|
+
raise ManualPdfError(f"Invalid notebook JSON in computation source {path}: {exc}") from exc
|
|
558
|
+
if not isinstance(notebook, dict):
|
|
559
|
+
raise ManualPdfError(f"Computation notebook must contain a JSON object: {path}")
|
|
560
|
+
metadata = notebook.get("metadata")
|
|
561
|
+
if isinstance(metadata, dict) and isinstance(metadata.get("unaltraweb_front_matter"), dict):
|
|
562
|
+
return metadata["unaltraweb_front_matter"]
|
|
563
|
+
for cell in notebook.get("cells", []):
|
|
564
|
+
if not isinstance(cell, dict) or cell.get("cell_type") not in {"raw", "markdown"}:
|
|
565
|
+
continue
|
|
566
|
+
source = cell.get("source", "")
|
|
567
|
+
cell_text = "".join(source) if isinstance(source, list) else str(source)
|
|
568
|
+
match = FRONT_MATTER_RE.match(cell_text)
|
|
569
|
+
text = match.group(1) if match else ""
|
|
570
|
+
break
|
|
571
|
+
if not text:
|
|
572
|
+
return {}
|
|
573
|
+
try:
|
|
574
|
+
parsed = yaml.safe_load(text)
|
|
575
|
+
except yaml.YAMLError as exc:
|
|
576
|
+
raise ManualPdfError(f"Invalid computation front matter in {path}: {exc}") from exc
|
|
577
|
+
if parsed is None:
|
|
578
|
+
return {}
|
|
579
|
+
if not isinstance(parsed, dict):
|
|
580
|
+
raise ManualPdfError(f"Computation front matter must be a YAML mapping: {path}")
|
|
581
|
+
return parsed
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def split_url_decoration(raw: str) -> tuple[str, str]:
|
|
585
|
+
indexes = [index for token in ("?", "#") if (index := raw.find(token)) >= 0]
|
|
586
|
+
if not indexes:
|
|
587
|
+
return raw, ""
|
|
588
|
+
index = min(indexes)
|
|
589
|
+
return raw[:index], raw[index:]
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def load_vega_manifest(project: Path) -> list[dict[str, str]]:
|
|
593
|
+
project = project.resolve()
|
|
594
|
+
manifest_path = project / VEGA_MANIFEST_NAME
|
|
595
|
+
if not manifest_path.is_file():
|
|
596
|
+
raise ManualPdfError(f"Missing required Vega visualization manifest: {VEGA_MANIFEST_NAME}")
|
|
597
|
+
if manifest_path.stat().st_size > 1024 * 1024:
|
|
598
|
+
raise ManualPdfError(f"Vega visualization manifest exceeds 1048576 bytes: {VEGA_MANIFEST_NAME}")
|
|
599
|
+
try:
|
|
600
|
+
text = manifest_path.read_text(encoding="utf-8")
|
|
601
|
+
if any(isinstance(token, yaml.tokens.AliasToken) for token in yaml.scan(text)):
|
|
602
|
+
raise ManualPdfError(f"YAML aliases are not allowed in {VEGA_MANIFEST_NAME}")
|
|
603
|
+
manifest = yaml.load(text, Loader=UniqueKeySafeLoader)
|
|
604
|
+
except ManualPdfError:
|
|
605
|
+
raise
|
|
606
|
+
except (OSError, UnicodeDecodeError, yaml.YAMLError) as exc:
|
|
607
|
+
raise ManualPdfError(f"Invalid Vega visualization manifest {VEGA_MANIFEST_NAME}: {exc}") from exc
|
|
608
|
+
|
|
609
|
+
if not isinstance(manifest, dict):
|
|
610
|
+
raise ManualPdfError(f"Vega visualization manifest must be a mapping: {VEGA_MANIFEST_NAME}")
|
|
611
|
+
if type(manifest.get("version")) is not int or manifest.get("version") != 1:
|
|
612
|
+
raise ManualPdfError("Vega visualization manifest version must be 1")
|
|
613
|
+
for field in ("profile", "family"):
|
|
614
|
+
if not isinstance(manifest.get(field), str) or not manifest[field]:
|
|
615
|
+
raise ManualPdfError(f"Vega visualization manifest requires a {field}")
|
|
616
|
+
visualizations = manifest.get("visualizations")
|
|
617
|
+
if not isinstance(visualizations, list):
|
|
618
|
+
raise ManualPdfError("Vega visualization manifest visualizations must be a list")
|
|
619
|
+
|
|
620
|
+
names: set[str] = set()
|
|
621
|
+
sources: set[str] = set()
|
|
622
|
+
outputs: set[str] = set()
|
|
623
|
+
normalized: list[dict[str, str]] = []
|
|
624
|
+
for index, item in enumerate(visualizations):
|
|
625
|
+
if not isinstance(item, dict):
|
|
626
|
+
raise ManualPdfError(f"Vega visualization at index {index} must be a mapping")
|
|
627
|
+
name = item.get("name")
|
|
628
|
+
if not isinstance(name, str) or not VEGA_SAFE_NAME_RE.fullmatch(name):
|
|
629
|
+
raise ManualPdfError(f"Vega visualization at index {index} has an invalid name")
|
|
630
|
+
if name in names:
|
|
631
|
+
raise ManualPdfError(f"Duplicate Vega visualization name: {name}")
|
|
632
|
+
source_raw = item.get("source")
|
|
633
|
+
output_raw = item.get("output")
|
|
634
|
+
if not isinstance(source_raw, str) or not source_raw:
|
|
635
|
+
raise ManualPdfError(f"Vega visualization {name} requires a source")
|
|
636
|
+
if not isinstance(output_raw, str) or not output_raw:
|
|
637
|
+
raise ManualPdfError(f"Vega visualization {name} requires an output")
|
|
638
|
+
|
|
639
|
+
source = safe_relative(project, source_raw, label=f"source for Vega visualization {name}", must_exist=True)
|
|
640
|
+
output = safe_relative(project, output_raw, label=f"output for Vega visualization {name}")
|
|
641
|
+
if not source.is_file():
|
|
642
|
+
raise ManualPdfError(f"Source for Vega visualization {name} is not a file: {source_raw}")
|
|
643
|
+
source_relative = source.relative_to(project).as_posix()
|
|
644
|
+
output_relative = output.relative_to(project).as_posix()
|
|
645
|
+
if source == output:
|
|
646
|
+
raise ManualPdfError(f"Vega visualization {name} output cannot replace its source")
|
|
647
|
+
if source_relative in sources:
|
|
648
|
+
raise ManualPdfError(f"Duplicate Vega visualization source: {source_relative}")
|
|
649
|
+
if output_relative in outputs:
|
|
650
|
+
raise ManualPdfError(f"Duplicate Vega visualization output: {output_relative}")
|
|
651
|
+
|
|
652
|
+
output_suffix = output.suffix.lower()
|
|
653
|
+
if output_suffix not in VEGA_OUTPUT_SUFFIXES:
|
|
654
|
+
raise ManualPdfError(f"Vega visualization {name} output must use .svg, .png, or .pdf")
|
|
655
|
+
item_format = item.get("format")
|
|
656
|
+
if item_format is not None and not isinstance(item_format, str):
|
|
657
|
+
raise ManualPdfError(f"Vega visualization {name} format must be a string")
|
|
658
|
+
if item_format and f".{item_format.strip().lower()}" != output_suffix:
|
|
659
|
+
raise ManualPdfError(f"Vega visualization {name} output suffix does not match format {item_format}")
|
|
660
|
+
if not isinstance(item.get("engine", "auto"), str):
|
|
661
|
+
raise ManualPdfError(f"Vega visualization {name} engine must be a string")
|
|
662
|
+
inputs = item.get("inputs", [])
|
|
663
|
+
if not isinstance(inputs, list) or not all(isinstance(entry, str) and entry for entry in inputs):
|
|
664
|
+
raise ManualPdfError(f"Vega visualization {name} inputs must be a list of paths")
|
|
665
|
+
|
|
666
|
+
names.add(name)
|
|
667
|
+
sources.add(source_relative)
|
|
668
|
+
outputs.add(output_relative)
|
|
669
|
+
normalized.append({"name": name, "source": source_relative, "output": output_relative})
|
|
670
|
+
return normalized
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def resolve_vega_source(project: Path, source_path: str) -> str:
|
|
674
|
+
project = project.resolve()
|
|
675
|
+
if REMOTE_PATH_RE.match(source_path):
|
|
676
|
+
raise ManualPdfError(f"Vega visualization source must be a local project path: {source_path}")
|
|
677
|
+
source = safe_relative(project, source_path, label="Vega visualization source", must_exist=True)
|
|
678
|
+
if not source.is_file():
|
|
679
|
+
raise ManualPdfError(f"Vega visualization source is not a file: {source_path}")
|
|
680
|
+
source_relative = source.relative_to(project).as_posix()
|
|
681
|
+
matches = [item for item in load_vega_manifest(project) if item["source"] == source_relative]
|
|
682
|
+
if not matches:
|
|
683
|
+
raise ManualPdfError(f"Vega visualization source is not declared in {VEGA_MANIFEST_NAME}: {source_relative}")
|
|
684
|
+
if len(matches) > 1:
|
|
685
|
+
raise ManualPdfError(f"Vega visualization source is ambiguous in {VEGA_MANIFEST_NAME}: {source_relative}")
|
|
686
|
+
output_relative = matches[0]["output"]
|
|
687
|
+
output = safe_relative(project, output_relative, label="Vega visualization output")
|
|
688
|
+
if not output.is_file():
|
|
689
|
+
raise ManualPdfError(f"Missing rendered Vega visualization output: {output_relative}")
|
|
690
|
+
return output.relative_to(project).as_posix()
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def localized_visual_source(
|
|
694
|
+
project: Path,
|
|
695
|
+
path: str,
|
|
696
|
+
*,
|
|
697
|
+
language: str = "",
|
|
698
|
+
default_language: str = "",
|
|
699
|
+
languages: list[str] | None = None,
|
|
700
|
+
) -> str:
|
|
701
|
+
current = language.strip()
|
|
702
|
+
default = default_language.strip()
|
|
703
|
+
if not current or current == default:
|
|
704
|
+
return path
|
|
705
|
+
suffix = next((candidate for candidate in VISUAL_LOCALIZATION_SUFFIXES if path.lower().endswith(candidate)), "")
|
|
706
|
+
if not suffix:
|
|
707
|
+
return path
|
|
708
|
+
stem = path[:-len(suffix)]
|
|
709
|
+
configured = {str(value).strip().lower() for value in (languages or []) if str(value).strip()}
|
|
710
|
+
configured.update({current.lower(), default.lower()})
|
|
711
|
+
if any(stem.lower().endswith(f".{code}") for code in configured):
|
|
712
|
+
return path
|
|
713
|
+
localized = f"{stem}.{current}{suffix}"
|
|
714
|
+
candidate = (project.resolve() / localized.lstrip("/")).resolve()
|
|
715
|
+
try:
|
|
716
|
+
candidate.relative_to(project.resolve())
|
|
717
|
+
except ValueError:
|
|
718
|
+
return path
|
|
719
|
+
return localized if candidate.is_file() else path
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def resolve_visual_source(
|
|
723
|
+
project: Path,
|
|
724
|
+
raw_path: str,
|
|
725
|
+
*,
|
|
726
|
+
language: str = "",
|
|
727
|
+
default_language: str = "",
|
|
728
|
+
languages: list[str] | None = None,
|
|
729
|
+
) -> str:
|
|
730
|
+
undecorated, _ = split_url_decoration(raw_path)
|
|
731
|
+
if REMOTE_PATH_RE.match(undecorated):
|
|
732
|
+
if undecorated.lower().endswith(VEGA_SOURCE_SUFFIXES):
|
|
733
|
+
raise ManualPdfError(f"Vega visualization source must be a local project path: {undecorated}")
|
|
734
|
+
return raw_path
|
|
735
|
+
path = localized_visual_source(
|
|
736
|
+
project,
|
|
737
|
+
undecorated.lstrip("/"),
|
|
738
|
+
language=language,
|
|
739
|
+
default_language=default_language,
|
|
740
|
+
languages=languages,
|
|
741
|
+
)
|
|
742
|
+
vega_path = path.lstrip("/")
|
|
743
|
+
if vega_path.lower().endswith(VEGA_SOURCE_SUFFIXES):
|
|
744
|
+
return resolve_vega_source(project, vega_path)
|
|
745
|
+
path = path.lstrip("/")
|
|
746
|
+
if path.lower().endswith((".capture.yml", ".capture.yaml")):
|
|
747
|
+
source = safe_relative(project, path, label="web capture source", must_exist=True)
|
|
748
|
+
base = str(source).rsplit(".", 1)[0]
|
|
749
|
+
candidates = [Path(base + ".edited.svg"), Path(base + ".svg")]
|
|
750
|
+
for candidate in candidates:
|
|
751
|
+
if candidate.is_file():
|
|
752
|
+
validate_capture_svg(candidate)
|
|
753
|
+
return str(candidate.relative_to(project))
|
|
754
|
+
raise ManualPdfError(f"No printable SVG found for web capture source: {path}")
|
|
755
|
+
suffix = Path(path).suffix.lower()
|
|
756
|
+
if suffix in COMPUTATION_SUFFIXES:
|
|
757
|
+
source = safe_relative(project, path, label="computation figure source", must_exist=True)
|
|
758
|
+
front = computation_front_matter(source)
|
|
759
|
+
metadata = front.get("unaltraweb_compute")
|
|
760
|
+
if not isinstance(metadata, dict) or str(metadata.get("mode") or "").strip().lower() != "figure":
|
|
761
|
+
raise ManualPdfError(f"Computation source is not declared as unaltraweb_compute.mode: figure: {path}")
|
|
762
|
+
outputs = metadata.get("outputs")
|
|
763
|
+
if outputs is None and metadata.get("output"):
|
|
764
|
+
outputs = [metadata["output"]]
|
|
765
|
+
if not isinstance(outputs, list) or not outputs or not str(outputs[0] or "").strip():
|
|
766
|
+
raise ManualPdfError(f"Figure computation source declares no outputs: {path}")
|
|
767
|
+
output_raw = str(outputs[0]).strip()
|
|
768
|
+
output = safe_relative(project, output_raw, label="computed figure output")
|
|
769
|
+
if output.suffix.lower() not in GENERATED_MEDIA_SUFFIXES:
|
|
770
|
+
raise ManualPdfError(f"Unsupported computed figure output type for {path}: {output_raw}")
|
|
771
|
+
edited = output.with_suffix(".edited.svg")
|
|
772
|
+
if edited.is_file():
|
|
773
|
+
return str(edited.relative_to(project))
|
|
774
|
+
if output.is_file():
|
|
775
|
+
return str(output.relative_to(project))
|
|
776
|
+
raise ManualPdfError(
|
|
777
|
+
f"Missing rendered figure {output_raw} for computation source {path}. "
|
|
778
|
+
"Run `make manual-compute-render-figures` before building the PDF."
|
|
779
|
+
)
|
|
780
|
+
if suffix not in DIAGRAM_SUFFIXES:
|
|
781
|
+
return path
|
|
782
|
+
source = safe_relative(project, path, label="diagram source", must_exist=True)
|
|
783
|
+
candidates = [Path(str(source) + ".edited.svg"), Path(str(source) + ".svg")]
|
|
784
|
+
for candidate in candidates:
|
|
785
|
+
if candidate.is_file():
|
|
786
|
+
return str(candidate.relative_to(project))
|
|
787
|
+
raise ManualPdfError(f"No printable SVG found for diagram source: {path}")
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def pandoc_image_attributes(raw: str) -> str:
|
|
791
|
+
source = raw.strip()
|
|
792
|
+
if not source:
|
|
793
|
+
return ""
|
|
794
|
+
source = source.removeprefix("{: ").removeprefix("{:").removesuffix("}").strip()
|
|
795
|
+
source = source.translate({0x2018: ord("'"), 0x2019: ord("'"), 0x201C: ord('"'), 0x201D: ord('"')})
|
|
796
|
+
dimensions: dict[str, str] = {}
|
|
797
|
+
while dimension_match := FIGURE_DIMENSION_ATTR_RE.search(source):
|
|
798
|
+
dimensions[dimension_match.group("name")] = next(
|
|
799
|
+
value for value in dimension_match.group("double", "single", "bare") if value is not None
|
|
800
|
+
).strip()
|
|
801
|
+
source = f"{source[:dimension_match.start()]} {source[dimension_match.end():]}".strip()
|
|
802
|
+
|
|
803
|
+
width = dimensions.get("data-figure-width-pdf") or dimensions.get("data-figure-width") or ""
|
|
804
|
+
height = dimensions.get("data-figure-height-pdf") or dimensions.get("data-figure-height") or ""
|
|
805
|
+
rem_width = re.fullmatch(r"([0-9]+(?:\.[0-9]+)?)rem", width, re.IGNORECASE)
|
|
806
|
+
if rem_width:
|
|
807
|
+
percentage = min(100.0, float(rem_width.group(1)) / 60.0 * 100.0)
|
|
808
|
+
width = f"{percentage:.4f}".rstrip("0").rstrip(".") + "%"
|
|
809
|
+
rem_height = re.fullmatch(r"([0-9]+(?:\.[0-9]+)?)rem", height, re.IGNORECASE)
|
|
810
|
+
if rem_height:
|
|
811
|
+
height = f"{float(rem_height.group(1)) * 11.0:.4f}".rstrip("0").rstrip(".") + "pt"
|
|
812
|
+
|
|
813
|
+
attributes = [
|
|
814
|
+
value
|
|
815
|
+
for value in (
|
|
816
|
+
source,
|
|
817
|
+
f"width={width}" if width else "",
|
|
818
|
+
f"height={height}" if height else "",
|
|
819
|
+
)
|
|
820
|
+
if value
|
|
821
|
+
]
|
|
822
|
+
return "{" + " ".join(attributes) + "}" if attributes else ""
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def latex_escape(value: str) -> str:
|
|
826
|
+
replacements = {
|
|
827
|
+
"\\": r"\textbackslash{}",
|
|
828
|
+
"{": r"\{",
|
|
829
|
+
"}": r"\}",
|
|
830
|
+
"$": r"\$",
|
|
831
|
+
"&": r"\&",
|
|
832
|
+
"#": r"\#",
|
|
833
|
+
"_": r"\_",
|
|
834
|
+
"%": r"\%",
|
|
835
|
+
"~": r"\textasciitilde{}",
|
|
836
|
+
"^": r"\textasciicircum{}",
|
|
837
|
+
}
|
|
838
|
+
return "".join(replacements.get(character, character) for character in value)
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def transform_markdown(
|
|
842
|
+
project: Path,
|
|
843
|
+
text: str,
|
|
844
|
+
source: Path,
|
|
845
|
+
language: str = "en",
|
|
846
|
+
config: dict[str, Any] | None = None,
|
|
847
|
+
citation_keys: list[str] | None = None,
|
|
848
|
+
) -> str:
|
|
849
|
+
callout_labels, _ = resolve_callout_labels(project, config or {}, language)
|
|
850
|
+
visual_default_language = str((config or {}).get("default_lang") or (config or {}).get("lang") or language)
|
|
851
|
+
configured_visual_languages = (config or {}).get("languages")
|
|
852
|
+
if not isinstance(configured_visual_languages, list):
|
|
853
|
+
configured_visual_languages = [visual_default_language]
|
|
854
|
+
token_prefix = "UNALTRAWEBMANUALPROTECTED"
|
|
855
|
+
while token_prefix in text:
|
|
856
|
+
token_prefix += "X"
|
|
857
|
+
protected: list[str] = []
|
|
858
|
+
|
|
859
|
+
def protect(value: str) -> str:
|
|
860
|
+
token = f"{token_prefix}_{len(protected)}_"
|
|
861
|
+
protected.append(value)
|
|
862
|
+
return token
|
|
863
|
+
|
|
864
|
+
protected_token_re = re.compile(rf"{re.escape(token_prefix)}_(\d+)_")
|
|
865
|
+
|
|
866
|
+
def latex_caption(value: str) -> str:
|
|
867
|
+
chunks: list[str] = []
|
|
868
|
+
position = 0
|
|
869
|
+
for token in protected_token_re.finditer(value):
|
|
870
|
+
chunks.append(latex_escape(value[position:token.start()]))
|
|
871
|
+
protected_value = protected[int(token.group(1))]
|
|
872
|
+
inline_match = MARKDOWN_INLINE_CODE_RE.fullmatch(protected_value)
|
|
873
|
+
if inline_match:
|
|
874
|
+
fence_length = len(inline_match.group(1))
|
|
875
|
+
code = protected_value[fence_length:-fence_length]
|
|
876
|
+
chunks.append(r"\texttt{" + latex_escape(code) + "}")
|
|
877
|
+
else:
|
|
878
|
+
chunks.append(latex_escape(protected_value))
|
|
879
|
+
position = token.end()
|
|
880
|
+
chunks.append(latex_escape(value[position:]))
|
|
881
|
+
return "".join(chunks)
|
|
882
|
+
|
|
883
|
+
def citations(match: re.Match[str]) -> str:
|
|
884
|
+
keys = [key.lstrip("@").strip() for key in match.group(1).split() if key.strip()]
|
|
885
|
+
if citation_keys is not None:
|
|
886
|
+
citation_keys.extend(key for key in keys if key not in citation_keys)
|
|
887
|
+
return "[" + "; ".join(f"@{key}" for key in keys) + "]"
|
|
888
|
+
|
|
889
|
+
def table(match: re.Match[str]) -> str:
|
|
890
|
+
body = match.group(2).strip()
|
|
891
|
+
rows = [
|
|
892
|
+
re.sub(r"\s+", " ", line.strip().strip("|"))
|
|
893
|
+
for line in body.splitlines()
|
|
894
|
+
if "|" in line and not PIPE_TABLE_SEPARATOR_RE.fullmatch(line)
|
|
895
|
+
]
|
|
896
|
+
estimated_lines = 2 + sum(max(1, (len(row) + 71) // 72) for row in rows)
|
|
897
|
+
required_baselines = max(10, round(estimated_lines * 1.25) + 2)
|
|
898
|
+
page_guard = r"\clearpage" if required_baselines >= 40 else f"\\Needspace{{{required_baselines}\\baselineskip}}"
|
|
899
|
+
return (
|
|
900
|
+
f"```{{=latex}}\n{page_guard}\n```\n\n"
|
|
901
|
+
f"Table: {match.group(1).strip()}\n\n{body}"
|
|
902
|
+
)
|
|
903
|
+
|
|
904
|
+
def listing(match: re.Match[str]) -> str:
|
|
905
|
+
token_match = protected_token_re.fullmatch(match.group("body").strip())
|
|
906
|
+
if not token_match:
|
|
907
|
+
raise ManualPdfError(
|
|
908
|
+
f"Listing block must contain exactly one fenced code block in {source.relative_to(project)}"
|
|
909
|
+
)
|
|
910
|
+
|
|
911
|
+
body = protected[int(token_match.group(1))]
|
|
912
|
+
opening_end = body.find("\n")
|
|
913
|
+
opening = body[:opening_end]
|
|
914
|
+
opening_match = re.fullmatch(r"(?P<indent>[ \t]*)(?P<fence>`{3,}|~{3,})(?P<info>[^\n]*)", opening)
|
|
915
|
+
if not opening_match: # pragma: no cover - guarded by FENCED_CODE_BLOCK_RE
|
|
916
|
+
raise ManualPdfError(f"Malformed listing fence in {source.relative_to(project)}")
|
|
917
|
+
|
|
918
|
+
caption = match.group("caption").replace("\\", "\\\\").replace('"', '\\"')
|
|
919
|
+
info = opening_match.group("info").strip()
|
|
920
|
+
if info.startswith("{") and info.endswith("}"):
|
|
921
|
+
attributes = info[:-1].rstrip()
|
|
922
|
+
attributes += f' data-listing-caption="{caption}"}}'
|
|
923
|
+
else:
|
|
924
|
+
language_class = f".{info} " if info else ""
|
|
925
|
+
attributes = f'{{{language_class}data-listing-caption="{caption}"}}'
|
|
926
|
+
rewritten = (
|
|
927
|
+
f"{opening_match.group('indent')}{opening_match.group('fence')}{attributes}"
|
|
928
|
+
f"{body[opening_end:]}"
|
|
929
|
+
)
|
|
930
|
+
return protect(rewritten)
|
|
931
|
+
|
|
932
|
+
def inline_code(match: re.Match[str]) -> str:
|
|
933
|
+
value = html.unescape(match.group(1))
|
|
934
|
+
longest_fence = max((len(run) for run in re.findall(r"`+", value)), default=0) + 1
|
|
935
|
+
fence = "`" * longest_fence
|
|
936
|
+
padding = " " if value.startswith("`") or value.endswith("`") else ""
|
|
937
|
+
return f"{fence}{padding}{value}{padding}{fence}"
|
|
938
|
+
|
|
939
|
+
def table_guard_before_heading(match: re.Match[str]) -> str:
|
|
940
|
+
guard = match.group("guard")
|
|
941
|
+
if match.group("baselines"):
|
|
942
|
+
guard = f"\\Needspace{{{int(match.group('baselines')) + 6}\\baselineskip}}"
|
|
943
|
+
return f"```{{=latex}}\n{guard}\n```\n\n{match.group('heading')}"
|
|
944
|
+
|
|
945
|
+
def display_math(match: re.Match[str]) -> str:
|
|
946
|
+
body = match.group("body").rstrip("\r\n")
|
|
947
|
+
return f"\\begin{{equation}}\n{body}\n\\end{{equation}}"
|
|
948
|
+
|
|
949
|
+
def image(match: re.Match[str]) -> str:
|
|
950
|
+
alt = match.group("alt")
|
|
951
|
+
raw_path = match.group("path")
|
|
952
|
+
title = match.group("double_title") or match.group("single_title")
|
|
953
|
+
printable = resolve_visual_source(
|
|
954
|
+
project,
|
|
955
|
+
raw_path,
|
|
956
|
+
language=language,
|
|
957
|
+
default_language=visual_default_language,
|
|
958
|
+
languages=[str(value) for value in configured_visual_languages],
|
|
959
|
+
)
|
|
960
|
+
caption = title or alt
|
|
961
|
+
attributes = pandoc_image_attributes(match.group("attrs") or "")
|
|
962
|
+
return f"{attributes}"
|
|
963
|
+
|
|
964
|
+
def callout(match: re.Match[str]) -> str:
|
|
965
|
+
marks = match.group("marks")
|
|
966
|
+
depth = min(len(marks), max(CALLOUT_STYLES))
|
|
967
|
+
color, callout_type = CALLOUT_STYLES[depth]
|
|
968
|
+
body = "\n".join(line[len(marks):].lstrip(" \t") for line in match.group("block").splitlines()).strip()
|
|
969
|
+
if not body:
|
|
970
|
+
raise ManualPdfError(f"Empty callout in {source.relative_to(project)}")
|
|
971
|
+
estimated_lines = 2 + sum(
|
|
972
|
+
max(1, (len(line.strip()) + 71) // 72)
|
|
973
|
+
for line in body.splitlines()
|
|
974
|
+
if line.strip()
|
|
975
|
+
)
|
|
976
|
+
options = "[enhanced,breakable]" if estimated_lines >= 40 else ""
|
|
977
|
+
label = latex_escape(callout_labels[callout_type])
|
|
978
|
+
return (
|
|
979
|
+
"```{=latex}\n"
|
|
980
|
+
f"\\begin{{manualcallout}}{options}{{{color}}}{{{label}}}\n"
|
|
981
|
+
"```\n\n"
|
|
982
|
+
f"{body}\n\n"
|
|
983
|
+
"```{=latex}\n"
|
|
984
|
+
"\\end{manualcallout}\n"
|
|
985
|
+
"```"
|
|
986
|
+
)
|
|
987
|
+
|
|
988
|
+
def subfigures(match: re.Match[str]) -> str:
|
|
989
|
+
def raw_latex_inline(value: str) -> str:
|
|
990
|
+
fence = "`" * (max((len(run) for run in re.findall(r"`+", value)), default=0) + 1)
|
|
991
|
+
padding = " " if value.startswith("`") or value.endswith("`") else ""
|
|
992
|
+
return f"{fence}{padding}{value}{padding}{fence}{{=latex}}"
|
|
993
|
+
|
|
994
|
+
images = list(IMAGE_RE.finditer(match.group("body")))
|
|
995
|
+
if not images:
|
|
996
|
+
raise ManualPdfError(f"Subfigures block contains no images in {source.relative_to(project)}")
|
|
997
|
+
|
|
998
|
+
layout = match.group("layout") or ""
|
|
999
|
+
row_sizes = [len([token for token in row.split("+") if token.strip()]) for row in layout.split("/") if row.strip()]
|
|
1000
|
+
if not row_sizes:
|
|
1001
|
+
row_sizes = [min(2, len(images))]
|
|
1002
|
+
while sum(row_sizes) < len(images):
|
|
1003
|
+
row_sizes.append(min(2, len(images) - sum(row_sizes)))
|
|
1004
|
+
if sum(row_sizes) != len(images):
|
|
1005
|
+
raise ManualPdfError(
|
|
1006
|
+
f"Subfigures layout '{layout}' declares {sum(row_sizes)} panels but contains {len(images)} images "
|
|
1007
|
+
f"in {source.relative_to(project)}"
|
|
1008
|
+
)
|
|
1009
|
+
|
|
1010
|
+
overall_caption = latex_caption(match.group("caption") or "")
|
|
1011
|
+
figure_start = "```{=latex}\n\\begin{figure}[H]\n\\centering\n"
|
|
1012
|
+
if overall_caption:
|
|
1013
|
+
figure_start += (
|
|
1014
|
+
f"\\caption{{{overall_caption}}}\n"
|
|
1015
|
+
"{\\color{ManualMuted!45}\\rule{\\linewidth}{0.35pt}}\\par\\medskip\n"
|
|
1016
|
+
)
|
|
1017
|
+
rendered = [figure_start + "```"]
|
|
1018
|
+
image_index = 0
|
|
1019
|
+
max_image_height = f"{0.52 / len(row_sizes):.3f}".rstrip("0").rstrip(".")
|
|
1020
|
+
for row_index, row_size in enumerate(row_sizes):
|
|
1021
|
+
panel_width = {1: "0.92", 2: "0.48", 3: "0.31", 4: "0.23"}.get(row_size, f"{0.92 / row_size:.3f}")
|
|
1022
|
+
row: list[str] = []
|
|
1023
|
+
for column_index in range(row_size):
|
|
1024
|
+
item = images[image_index]
|
|
1025
|
+
image_index += 1
|
|
1026
|
+
caption = item.group("double_title") or item.group("single_title") or item.group("alt")
|
|
1027
|
+
printable = resolve_visual_source(
|
|
1028
|
+
project,
|
|
1029
|
+
item.group("path"),
|
|
1030
|
+
language=language,
|
|
1031
|
+
default_language=visual_default_language,
|
|
1032
|
+
languages=[str(value) for value in configured_visual_languages],
|
|
1033
|
+
)
|
|
1034
|
+
attributes = pandoc_image_attributes(item.group("attrs") or "")
|
|
1035
|
+
row.extend([
|
|
1036
|
+
raw_latex_inline(
|
|
1037
|
+
f"\\begin{{subfigure}}[t]{{{panel_width}\\linewidth}}"
|
|
1038
|
+
"\\centering"
|
|
1039
|
+
f"\\def\\maxheight{{{max_image_height}\\textheight}}"
|
|
1040
|
+
),
|
|
1041
|
+
f"{attributes}",
|
|
1042
|
+
])
|
|
1043
|
+
separator = r"\hfill" if column_index < row_size - 1 else ""
|
|
1044
|
+
row.append(raw_latex_inline(
|
|
1045
|
+
f"\\caption{{{latex_caption(caption)}}}"
|
|
1046
|
+
f"\\end{{subfigure}}{separator}"
|
|
1047
|
+
))
|
|
1048
|
+
rendered.append("".join(row))
|
|
1049
|
+
if row_index < len(row_sizes) - 1:
|
|
1050
|
+
rendered.append("```{=latex}\n\\par\\medskip\n```")
|
|
1051
|
+
|
|
1052
|
+
rendered.append("```{=latex}\n\\end{figure}\n```")
|
|
1053
|
+
return "\n\n".join(rendered)
|
|
1054
|
+
|
|
1055
|
+
transformed = FENCED_CODE_BLOCK_RE.sub(lambda match: protect(match.group(0)), text)
|
|
1056
|
+
transformed = LISTING_DIV_RE.sub(listing, transformed)
|
|
1057
|
+
transformed = MARKDOWN_INLINE_CODE_RE.sub(lambda match: protect(match.group(0)), transformed)
|
|
1058
|
+
transformed = INLINE_CODE_RE.sub(lambda match: protect(inline_code(match)), transformed)
|
|
1059
|
+
transformed = DISPLAY_MATH_BLOCK_RE.sub(display_math, transformed)
|
|
1060
|
+
transformed = transformed.replace(r"\begin{equation\*}", r"\begin{equation*}")
|
|
1061
|
+
transformed = transformed.replace(r"\end{equation\*}", r"\end{equation*}")
|
|
1062
|
+
transformed = BASEURL_RE.sub("", transformed)
|
|
1063
|
+
transformed = transformed.replace("{% include manual-bibliography.liquid %}", "::: {#refs}\n:::")
|
|
1064
|
+
transformed = CITE_RE.sub(citations, transformed)
|
|
1065
|
+
transformed = CALLOUT_BLOCK_RE.sub(callout, transformed)
|
|
1066
|
+
transformed = TABLE_DIV_RE.sub(table, transformed)
|
|
1067
|
+
transformed = TABLE_GUARD_AFTER_HEADING_RE.sub(table_guard_before_heading, transformed)
|
|
1068
|
+
transformed = SUBFIGURES_DIV_RE.sub(subfigures, transformed)
|
|
1069
|
+
transformed = IMAGE_RE.sub(image, transformed)
|
|
1070
|
+
if re.search(r"^:::\s*subfigures\b", transformed, re.MULTILINE):
|
|
1071
|
+
raise ManualPdfError(f"Malformed subfigures block in {source.relative_to(project)}")
|
|
1072
|
+
if re.search(r"^:::\s*listing\b", transformed, re.MULTILINE):
|
|
1073
|
+
raise ManualPdfError(f"Malformed listing block in {source.relative_to(project)}")
|
|
1074
|
+
unknown_includes = [item for item in INCLUDE_RE.findall(transformed) if item.strip()]
|
|
1075
|
+
unknown_liquid = [item for item in LIQUID_RE.findall(transformed) if item.strip()]
|
|
1076
|
+
if unknown_includes or unknown_liquid:
|
|
1077
|
+
token = (unknown_includes or unknown_liquid)[0]
|
|
1078
|
+
raise ManualPdfError(f"Unsupported Liquid in {source.relative_to(project)}: {token}")
|
|
1079
|
+
for index in range(len(protected) - 1, -1, -1):
|
|
1080
|
+
transformed = transformed.replace(f"{token_prefix}_{index}_", protected[index])
|
|
1081
|
+
return transformed.strip()
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
def localized(value: Any, lang: str) -> str:
|
|
1085
|
+
if isinstance(value, dict):
|
|
1086
|
+
return str(value.get(lang) or value.get("default") or next(iter(value.values()), ""))
|
|
1087
|
+
return str(value or "")
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
def build_metadata(project: Path, config: dict[str, Any], lang: str, source_lang: str, home_front: dict[str, Any], chapters: list[tuple[Path, dict[str, Any], str]]) -> dict[str, Any]:
|
|
1091
|
+
manual = nested(config, "unaltraweb", "manual")
|
|
1092
|
+
pdf = manual.get("pdf") if isinstance(manual.get("pdf"), dict) else {}
|
|
1093
|
+
metadata = manual.get("metadata") if isinstance(manual.get("metadata"), dict) else {}
|
|
1094
|
+
cover = pdf.get("cover") if isinstance(pdf.get("cover"), dict) else {}
|
|
1095
|
+
author = config.get("author") if isinstance(config.get("author"), dict) else {}
|
|
1096
|
+
instructors_raw = metadata.get("instructors")
|
|
1097
|
+
if isinstance(instructors_raw, list):
|
|
1098
|
+
instructors = [str(item.get("name") if isinstance(item, dict) else item) for item in instructors_raw]
|
|
1099
|
+
else:
|
|
1100
|
+
instructors = [localized(author.get(f"name_{source_lang}") or author.get("name"), source_lang)]
|
|
1101
|
+
instructors = [item for item in instructors if item]
|
|
1102
|
+
guides_raw = metadata.get("teaching_guides")
|
|
1103
|
+
teaching_guides: list[dict[str, str]] = []
|
|
1104
|
+
if isinstance(guides_raw, list):
|
|
1105
|
+
for item in guides_raw:
|
|
1106
|
+
if not isinstance(item, dict):
|
|
1107
|
+
continue
|
|
1108
|
+
degree = localized(item.get("degree"), source_lang)
|
|
1109
|
+
if degree:
|
|
1110
|
+
teaching_guides.append({"degree": degree, "subject-code": str(item.get("subject_code") or "")})
|
|
1111
|
+
if not teaching_guides:
|
|
1112
|
+
degree = localized(metadata.get("degree"), source_lang)
|
|
1113
|
+
if degree:
|
|
1114
|
+
teaching_guides.append({"degree": degree, "subject-code": str(metadata.get("subject_code") or "")})
|
|
1115
|
+
metadata_labels = METADATA_LABELS.get(source_lang, METADATA_LABELS["en"])
|
|
1116
|
+
unaltraweb = nested(config, "unaltraweb")
|
|
1117
|
+
status_field = str(unaltraweb.get("content_status_field") or "content_status")
|
|
1118
|
+
approved_status = str(unaltraweb.get("approved_status") or "approved")
|
|
1119
|
+
statuses = {str(front.get(status_field) or "") for _, front, _ in chapters}
|
|
1120
|
+
if home_front:
|
|
1121
|
+
statuses.add(str(home_front.get(status_field) or ""))
|
|
1122
|
+
draft = any(status != approved_status for status in statuses)
|
|
1123
|
+
|
|
1124
|
+
def asset(raw: Any) -> str:
|
|
1125
|
+
if not raw:
|
|
1126
|
+
return ""
|
|
1127
|
+
return str(safe_relative(project, str(raw), label="cover asset", must_exist=True).relative_to(project))
|
|
1128
|
+
|
|
1129
|
+
title = localized(metadata.get("title"), source_lang) or localized(home_front.get("title"), source_lang) or str(config.get("title") or "Manual")
|
|
1130
|
+
copyright_holder = str(config.get("copyright_holder") or "").strip()
|
|
1131
|
+
release = release_metadata()
|
|
1132
|
+
return {
|
|
1133
|
+
"title": title,
|
|
1134
|
+
"short-title": localized(metadata.get("short_title"), source_lang) or str(config.get("short_title") or title),
|
|
1135
|
+
"description": localized(metadata.get("description"), source_lang) or localized(home_front.get("description"), source_lang) or str(config.get("description") or ""),
|
|
1136
|
+
"author": ", ".join(instructors),
|
|
1137
|
+
"instructors": instructors,
|
|
1138
|
+
"series": localized(metadata.get("series"), source_lang) or "unaltremanual",
|
|
1139
|
+
"series-subtitle": localized(metadata.get("series_subtitle"), source_lang),
|
|
1140
|
+
"publisher": localized(metadata.get("publisher"), source_lang),
|
|
1141
|
+
"edition": localized(metadata.get("edition"), source_lang),
|
|
1142
|
+
"publication-date": localized(metadata.get("publication_date"), source_lang),
|
|
1143
|
+
**release,
|
|
1144
|
+
"identifier": localized(metadata.get("identifier"), source_lang),
|
|
1145
|
+
"license": localized(metadata.get("license"), source_lang),
|
|
1146
|
+
"source": localized(metadata.get("source"), source_lang),
|
|
1147
|
+
"subject": localized(metadata.get("subject"), source_lang),
|
|
1148
|
+
"subject-code": str(metadata.get("subject_code") or ""),
|
|
1149
|
+
"degree": localized(metadata.get("degree"), source_lang),
|
|
1150
|
+
"teaching-guides": teaching_guides,
|
|
1151
|
+
"department": localized(metadata.get("department"), source_lang),
|
|
1152
|
+
"faculty": localized(metadata.get("faculty"), source_lang),
|
|
1153
|
+
"institution": localized(metadata.get("institution"), source_lang) or localized(author.get(f"affiliation_{source_lang}") or author.get("affiliation"), source_lang),
|
|
1154
|
+
"location": localized(metadata.get("location"), source_lang) or localized(author.get(f"location_{source_lang}") or author.get("location"), source_lang),
|
|
1155
|
+
"academic-year": str(metadata.get("academic_year") or ""),
|
|
1156
|
+
"revision-date": localized(metadata.get("revision_date"), source_lang),
|
|
1157
|
+
"rights": localized(metadata.get("rights"), source_lang) or (f"© {copyright_holder}" if copyright_holder else ""),
|
|
1158
|
+
"metadata-page-title": metadata_labels["title"],
|
|
1159
|
+
"metadata-series-label": metadata_labels["series"],
|
|
1160
|
+
"metadata-publisher-label": metadata_labels["publisher"],
|
|
1161
|
+
"metadata-edition-label": metadata_labels["edition"],
|
|
1162
|
+
"metadata-publication-date-label": metadata_labels["publication_date"],
|
|
1163
|
+
"metadata-release-channel-label": metadata_labels["release_channel"],
|
|
1164
|
+
"metadata-release-version-label": metadata_labels["release_version"],
|
|
1165
|
+
"metadata-subject-label": metadata_labels["subject"],
|
|
1166
|
+
"metadata-teaching-guides-label": metadata_labels["teaching_guides"],
|
|
1167
|
+
"metadata-academic-year-label": metadata_labels["academic_year"],
|
|
1168
|
+
"metadata-department-label": metadata_labels["department"],
|
|
1169
|
+
"metadata-faculty-label": metadata_labels["faculty"],
|
|
1170
|
+
"metadata-institution-label": metadata_labels["institution"],
|
|
1171
|
+
"metadata-location-label": metadata_labels["location"],
|
|
1172
|
+
"metadata-revision-date-label": metadata_labels["revision_date"],
|
|
1173
|
+
"metadata-instructors-label": metadata_labels["instructors"],
|
|
1174
|
+
"metadata-identifier-label": metadata_labels["identifier"],
|
|
1175
|
+
"metadata-license-label": metadata_labels["license"],
|
|
1176
|
+
"metadata-source-label": metadata_labels["source"],
|
|
1177
|
+
"metadata-rights-label": metadata_labels["rights"],
|
|
1178
|
+
"chapter-references-title": metadata_labels["references"],
|
|
1179
|
+
"listing-label": metadata_labels["listing"],
|
|
1180
|
+
"list-of-figures-title": metadata_labels["list_of_figures"],
|
|
1181
|
+
"list-of-tables-title": metadata_labels["list_of_tables"],
|
|
1182
|
+
"list-of-listings-title": metadata_labels["list_of_listings"],
|
|
1183
|
+
"lang": lang,
|
|
1184
|
+
"babel-lang": LANGUAGE_NAMES.get(source_lang, "english"),
|
|
1185
|
+
"toc": bool(pdf.get("toc", True)),
|
|
1186
|
+
"nocite": "@*" if manual.get("bibliography", True) and bool(pdf.get("include_bibliography", True)) else "",
|
|
1187
|
+
"link-citations": bool(pdf.get("link_citations", True)),
|
|
1188
|
+
"draft": draft and bool(pdf.get("mark_drafts", True)),
|
|
1189
|
+
"draft-label": localized(pdf.get("draft_label"), source_lang) or {"ca": "ESBORRANY", "es": "BORRADOR", "en": "DRAFT"}.get(source_lang, "DRAFT"),
|
|
1190
|
+
"draft-description": localized(pdf.get("draft_description"), source_lang) or {"ca": "Material en revisió. No és una versió final.", "es": "Material en revisión. No es una versión final.", "en": "Material under review. This is not a final version."}.get(source_lang, "Material under review."),
|
|
1191
|
+
"primary-color": str(cover.get("primary_color") or "990000").lstrip("#"),
|
|
1192
|
+
"band-color": str(cover.get("band_color") or cover.get("primary_color") or "990000").lstrip("#"),
|
|
1193
|
+
"secondary-color": str(cover.get("secondary_color") or "003366").lstrip("#"),
|
|
1194
|
+
"muted-color": str(cover.get("muted_color") or "666666").lstrip("#"),
|
|
1195
|
+
"internal-link-color": str(pdf.get("internal_link_color") or cover.get("secondary_color") or "003366").lstrip("#"),
|
|
1196
|
+
"external-link-color": str(pdf.get("external_link_color") or cover.get("primary_color") or "990000").lstrip("#"),
|
|
1197
|
+
"citation-link-color": str(pdf.get("citation_link_color") or "C2185B").lstrip("#"),
|
|
1198
|
+
"inline-code-color": str(pdf.get("inline_code_color") or "5A1F5F").lstrip("#"),
|
|
1199
|
+
"cover-image": asset(cover.get("image")),
|
|
1200
|
+
"cover-logo": asset(cover.get("institution_logo")),
|
|
1201
|
+
"series-logo": asset(cover.get("series_logo")),
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
def assemble(project: Path, config: dict[str, Any], lang: str, paths: dict[str, Path]) -> tuple[dict[str, Any], list[Path], str]:
|
|
1206
|
+
manual = nested(config, "unaltraweb", "manual")
|
|
1207
|
+
pdf = manual.get("pdf") if isinstance(manual.get("pdf"), dict) else {}
|
|
1208
|
+
home, chapters, source_lang = manual_sources(project, config, lang)
|
|
1209
|
+
home_front: dict[str, Any] = {}
|
|
1210
|
+
chunks: list[str] = []
|
|
1211
|
+
_, callout_label_sources = resolve_callout_labels(project, config, source_lang)
|
|
1212
|
+
source_paths: list[Path] = list(callout_label_sources)
|
|
1213
|
+
includes_home = bool(home and pdf.get("include_home", True))
|
|
1214
|
+
|
|
1215
|
+
if includes_home:
|
|
1216
|
+
assert home is not None
|
|
1217
|
+
home_front, body = read_source(home)
|
|
1218
|
+
body = transform_markdown(project, body, home, source_lang, config)
|
|
1219
|
+
home_title = localized(pdf.get("home_title"), source_lang) or {"ca": "Inici i presentació del curs", "es": "Inicio y presentación del curso", "en": "Course introduction"}.get(source_lang, "Introduction")
|
|
1220
|
+
first_heading = re.compile(rf"^##\s+{re.escape(home_title)}\s*$", re.MULTILINE)
|
|
1221
|
+
body = first_heading.sub("", body, count=1).strip()
|
|
1222
|
+
chunks.append(f"# {home_title}\n\n{body}")
|
|
1223
|
+
source_paths.append(home)
|
|
1224
|
+
|
|
1225
|
+
included_chapters: list[tuple[Path, dict[str, Any], str]] = []
|
|
1226
|
+
for path, front, body in chapters:
|
|
1227
|
+
is_bibliography = front.get("manual_bibliography") is True or str(front.get("ref") or "") == "manual-bibliography"
|
|
1228
|
+
if is_bibliography and not bool(pdf.get("include_bibliography", True)):
|
|
1229
|
+
continue
|
|
1230
|
+
title = str(front.get("title") or path.stem)
|
|
1231
|
+
heading = f"# {title}"
|
|
1232
|
+
if front.get("manual_numbered") is False:
|
|
1233
|
+
heading += " {-}"
|
|
1234
|
+
citation_keys: list[str] = []
|
|
1235
|
+
transformed_body = transform_markdown(project, body, path, source_lang, config, citation_keys)
|
|
1236
|
+
references_requested = bool(front.get("related_publications") or front.get("manual_references") or front.get("references"))
|
|
1237
|
+
if citation_keys and references_requested and manual.get("bibliography", True) is not False:
|
|
1238
|
+
marker = html.escape(",".join(citation_keys), quote=True)
|
|
1239
|
+
transformed_body += f'\n\n::: {{.manual-chapter-citations data-citations="{marker}"}}\n:::'
|
|
1240
|
+
chunks.append(f"{heading}\n\n{transformed_body}")
|
|
1241
|
+
included_chapters.append((path, front, body))
|
|
1242
|
+
source_paths.append(path)
|
|
1243
|
+
|
|
1244
|
+
assembled_markdown = "\n\n\\newpage\n\n".join(chunks) + "\n"
|
|
1245
|
+
metadata = build_metadata(project, config, lang, source_lang, home_front, included_chapters)
|
|
1246
|
+
metadata["include-home"] = includes_home
|
|
1247
|
+
metadata["has-listings"] = "data-listing-caption=" in assembled_markdown
|
|
1248
|
+
prose_markdown = FENCED_CODE_BLOCK_RE.sub("", assembled_markdown)
|
|
1249
|
+
metadata["has-figures"] = bool(IMAGE_RE.search(prose_markdown) or r"\begin{figure}" in prose_markdown)
|
|
1250
|
+
metadata["has-tables"] = bool(re.search(r"^Table:\s+\S", prose_markdown, re.MULTILINE))
|
|
1251
|
+
return metadata, source_paths, assembled_markdown
|
|
1252
|
+
|
|
1253
|
+
|
|
1254
|
+
def bibliography_source(project: Path, config: dict[str, Any]) -> Path | None:
|
|
1255
|
+
manual = nested(config, "unaltraweb", "manual")
|
|
1256
|
+
if manual.get("bibliography", True) is False:
|
|
1257
|
+
return None
|
|
1258
|
+
filename = str(manual.get("bibliography_file") or "manual.bib").strip()
|
|
1259
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*\.bib", filename) or Path(filename).name != filename:
|
|
1260
|
+
raise ManualPdfError("unaltraweb.manual.bibliography_file must be a .bib filename under _bibliography/.")
|
|
1261
|
+
return safe_relative(project, f"_bibliography/{filename}", label="bibliography", must_exist=True)
|
|
1262
|
+
|
|
1263
|
+
|
|
1264
|
+
def clean_bibliography(source: Path | None, destination: Path) -> None:
|
|
1265
|
+
if source is None:
|
|
1266
|
+
return
|
|
1267
|
+
text = source.read_text(encoding="utf-8")
|
|
1268
|
+
text = FRONT_MATTER_RE.sub("", text, count=1)
|
|
1269
|
+
destination.write_text(text, encoding="utf-8")
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
def bibliography_custom_urls(text: str) -> dict[str, list[str]]:
|
|
1273
|
+
matches = list(BIB_ENTRY_HEADER_RE.finditer(text))
|
|
1274
|
+
urls: dict[str, list[str]] = {}
|
|
1275
|
+
for index, match in enumerate(matches):
|
|
1276
|
+
end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
|
|
1277
|
+
values = [(custom.group(1) or custom.group(2)).strip() for custom in BIB_CUSTOM_URL_RE.finditer(text, match.end(), end)]
|
|
1278
|
+
if values:
|
|
1279
|
+
urls[match.group(1)] = list(dict.fromkeys(values))
|
|
1280
|
+
return urls
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
def fold_bibliography_value(value: Any) -> str:
|
|
1284
|
+
decomposed = unicodedata.normalize("NFKD", str(value or "").casefold())
|
|
1285
|
+
return " ".join("".join(character for character in decomposed if not unicodedata.combining(character)).split())
|
|
1286
|
+
|
|
1287
|
+
|
|
1288
|
+
def contributor_sort_value(record: dict[str, Any]) -> str:
|
|
1289
|
+
contributors: Any = []
|
|
1290
|
+
for field in ("author", "editor", "collection-editor", "translator"):
|
|
1291
|
+
if isinstance(record.get(field), list) and record[field]:
|
|
1292
|
+
contributors = record[field]
|
|
1293
|
+
break
|
|
1294
|
+
values: list[str] = []
|
|
1295
|
+
for contributor in contributors:
|
|
1296
|
+
if not isinstance(contributor, dict):
|
|
1297
|
+
continue
|
|
1298
|
+
literal = str(contributor.get("literal") or "").strip()
|
|
1299
|
+
if literal:
|
|
1300
|
+
values.append(literal)
|
|
1301
|
+
continue
|
|
1302
|
+
family = str(contributor.get("family") or "").strip()
|
|
1303
|
+
given = str(contributor.get("given") or "").strip()
|
|
1304
|
+
values.append(", ".join(part for part in (family, given) if part))
|
|
1305
|
+
if values:
|
|
1306
|
+
return "; ".join(values)
|
|
1307
|
+
return str(record.get("publisher") or record.get("container-title") or record.get("title") or "")
|
|
1308
|
+
|
|
1309
|
+
|
|
1310
|
+
def normalize_doi(value: Any) -> str:
|
|
1311
|
+
return re.sub(r"(?i)^(?:https?://(?:dx\.)?doi\.org/|doi:\s*)", "", str(value or "").strip()).rstrip("/")
|
|
1312
|
+
|
|
1313
|
+
|
|
1314
|
+
def bibliography_filter_metadata(records: Any, custom_urls: dict[str, list[str]]) -> dict[str, dict[str, Any]]:
|
|
1315
|
+
if not isinstance(records, list):
|
|
1316
|
+
raise ManualPdfError("Pandoc returned invalid CSL bibliography data.")
|
|
1317
|
+
sort_keys: dict[str, str] = {}
|
|
1318
|
+
access: dict[str, dict[str, str]] = {}
|
|
1319
|
+
for record in records:
|
|
1320
|
+
if not isinstance(record, dict) or not str(record.get("id") or "").strip():
|
|
1321
|
+
continue
|
|
1322
|
+
key = str(record["id"]).strip()
|
|
1323
|
+
issued = record.get("issued") if isinstance(record.get("issued"), dict) else {}
|
|
1324
|
+
date_parts = issued.get("date-parts") if isinstance(issued, dict) else []
|
|
1325
|
+
year = ""
|
|
1326
|
+
if isinstance(date_parts, list) and date_parts and isinstance(date_parts[0], list) and date_parts[0]:
|
|
1327
|
+
year = str(date_parts[0][0])
|
|
1328
|
+
sort_keys[key] = " | ".join(
|
|
1329
|
+
fold_bibliography_value(value)
|
|
1330
|
+
for value in (contributor_sort_value(record), year, record.get("title"), key)
|
|
1331
|
+
)
|
|
1332
|
+
doi = normalize_doi(record.get("DOI") or record.get("doi"))
|
|
1333
|
+
standard_url = str(record.get("URL") or record.get("url") or "").strip()
|
|
1334
|
+
urls = list(dict.fromkeys(url for url in [standard_url, *custom_urls.get(key, [])] if url))
|
|
1335
|
+
normalized_doi = doi.casefold()
|
|
1336
|
+
if normalized_doi:
|
|
1337
|
+
urls = [
|
|
1338
|
+
url
|
|
1339
|
+
for url in urls
|
|
1340
|
+
if re.sub(r"(?i)^https?://(?:dx\.)?doi\.org/", "", url).rstrip("/").casefold() != normalized_doi
|
|
1341
|
+
]
|
|
1342
|
+
if doi or urls:
|
|
1343
|
+
access[key] = {"doi": doi, "urls": urls}
|
|
1344
|
+
return {"bibliography-sort-keys": sort_keys, "bibliography-access": access}
|
|
1345
|
+
|
|
1346
|
+
|
|
1347
|
+
def extract_bibliography_metadata(source: Path, destination: Path, project: Path) -> dict[str, dict[str, Any]]:
|
|
1348
|
+
csl_json = destination.with_suffix(".json")
|
|
1349
|
+
run_command(
|
|
1350
|
+
[
|
|
1351
|
+
"pandoc",
|
|
1352
|
+
str(destination),
|
|
1353
|
+
"--from=biblatex",
|
|
1354
|
+
"--to=csljson",
|
|
1355
|
+
f"--output={csl_json}",
|
|
1356
|
+
],
|
|
1357
|
+
project,
|
|
1358
|
+
)
|
|
1359
|
+
try:
|
|
1360
|
+
records = json.loads(csl_json.read_text(encoding="utf-8"))
|
|
1361
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
1362
|
+
raise ManualPdfError(f"Could not read Pandoc CSL bibliography data: {exc}") from exc
|
|
1363
|
+
metadata = bibliography_filter_metadata(records, bibliography_custom_urls(source.read_text(encoding="utf-8")))
|
|
1364
|
+
for record in records:
|
|
1365
|
+
if not isinstance(record, dict):
|
|
1366
|
+
continue
|
|
1367
|
+
doi = normalize_doi(record.get("DOI") or record.get("doi"))
|
|
1368
|
+
if doi:
|
|
1369
|
+
record["DOI"] = doi
|
|
1370
|
+
record.pop("doi", None)
|
|
1371
|
+
url = str(record.get("URL") or record.get("url") or "").strip()
|
|
1372
|
+
if normalize_doi(url).casefold() == doi.casefold() and re.match(r"(?i)^https?://(?:dx\.)?doi\.org/", url):
|
|
1373
|
+
record.pop("URL", None)
|
|
1374
|
+
record.pop("url", None)
|
|
1375
|
+
csl_json.write_text(json.dumps(records, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
1376
|
+
return metadata
|
|
1377
|
+
|
|
1378
|
+
|
|
1379
|
+
def configured_template(project: Path, config: dict[str, Any]) -> Path:
|
|
1380
|
+
pdf = nested(config, "unaltraweb", "manual", "pdf")
|
|
1381
|
+
template_raw = str(pdf.get("template") or "").strip()
|
|
1382
|
+
return safe_relative(project, template_raw, label="PDF template", must_exist=True) if template_raw else DEFAULT_TEMPLATE
|
|
1383
|
+
|
|
1384
|
+
|
|
1385
|
+
def configured_csl(project: Path, config: dict[str, Any], bibliography: Path | None) -> Path | None:
|
|
1386
|
+
if bibliography is None:
|
|
1387
|
+
return None
|
|
1388
|
+
pdf = nested(config, "unaltraweb", "manual", "pdf")
|
|
1389
|
+
csl_raw = str(pdf.get("csl") or "").strip()
|
|
1390
|
+
return safe_relative(project, csl_raw, label="CSL file", must_exist=True) if csl_raw else None
|
|
1391
|
+
|
|
1392
|
+
|
|
1393
|
+
def build_dependencies(project: Path, metadata: dict[str, Any], source_paths: list[Path], markdown: str, template: Path, csl: Path | None) -> list[tuple[str, Path]]:
|
|
1394
|
+
dependencies: list[tuple[str, Path]] = [
|
|
1395
|
+
("config:_config.yml", project / "_config.yml"),
|
|
1396
|
+
("builder:build_pdf.py", Path(__file__).resolve()),
|
|
1397
|
+
("toolchain:Dockerfile", DEFAULT_DOCKERFILE),
|
|
1398
|
+
(f"template:{template.name}", template),
|
|
1399
|
+
("filter:bibliography.lua", DEFAULT_BIBLIOGRAPHY_FILTER),
|
|
1400
|
+
("filter:code-blocks.lua", DEFAULT_CODE_BLOCK_FILTER),
|
|
1401
|
+
("filter:figure-captions.lua", DEFAULT_FIGURE_FILTER),
|
|
1402
|
+
]
|
|
1403
|
+
for path in source_paths:
|
|
1404
|
+
dependencies.append((f"source:{path.relative_to(project)}", path))
|
|
1405
|
+
if csl:
|
|
1406
|
+
dependencies.append((f"csl:{csl.relative_to(project)}", csl))
|
|
1407
|
+
for key in ["cover-image", "cover-logo", "series-logo"]:
|
|
1408
|
+
if metadata.get(key):
|
|
1409
|
+
path = safe_relative(project, str(metadata[key]), label=key, must_exist=True)
|
|
1410
|
+
dependencies.append((f"asset:{path.relative_to(project)}", path))
|
|
1411
|
+
dependency_markdown = FENCED_CODE_BLOCK_RE.sub("", markdown)
|
|
1412
|
+
dependency_markdown = MARKDOWN_INLINE_CODE_RE.sub("", dependency_markdown)
|
|
1413
|
+
for match in IMAGE_RE.finditer(dependency_markdown):
|
|
1414
|
+
raw = match.group("path")
|
|
1415
|
+
if raw.startswith(("http://", "https://", "data:", "#")):
|
|
1416
|
+
continue
|
|
1417
|
+
local_path, _ = split_url_decoration(raw)
|
|
1418
|
+
path = safe_relative(project, local_path, label="manual image", must_exist=True)
|
|
1419
|
+
dependencies.append((f"asset:{path.relative_to(project)}", path))
|
|
1420
|
+
unique: dict[str, Path] = {}
|
|
1421
|
+
for label, path in dependencies:
|
|
1422
|
+
unique[label] = path
|
|
1423
|
+
return sorted(unique.items())
|
|
1424
|
+
|
|
1425
|
+
|
|
1426
|
+
def dependency_fingerprint(dependencies: list[tuple[str, Path]], release: dict[str, str]) -> str:
|
|
1427
|
+
digest = hashlib.sha256()
|
|
1428
|
+
for label, path in dependencies:
|
|
1429
|
+
digest.update(label.encode("utf-8"))
|
|
1430
|
+
digest.update(b"\0")
|
|
1431
|
+
digest.update(path.read_bytes())
|
|
1432
|
+
digest.update(b"\0")
|
|
1433
|
+
digest.update(b"release-metadata\0")
|
|
1434
|
+
digest.update(json.dumps(release, sort_keys=True, separators=(",", ":")).encode("utf-8"))
|
|
1435
|
+
digest.update(b"\0")
|
|
1436
|
+
return digest.hexdigest()
|
|
1437
|
+
|
|
1438
|
+
|
|
1439
|
+
def file_signature(path: Path) -> dict[str, Any]:
|
|
1440
|
+
return {"sha256": hashlib.sha256(path.read_bytes()).hexdigest(), "size": path.stat().st_size}
|
|
1441
|
+
|
|
1442
|
+
|
|
1443
|
+
def prepare_build(project: Path, config: dict[str, Any], lang: str) -> tuple[dict[str, Any], list[Path], str, Path, Path | None, Path | None, list[tuple[str, Path]], str]:
|
|
1444
|
+
metadata, source_paths, markdown = assemble(project, config, lang, artifact_paths(project, config, lang))
|
|
1445
|
+
bibliography = bibliography_source(project, config)
|
|
1446
|
+
if bibliography:
|
|
1447
|
+
source_paths.append(bibliography)
|
|
1448
|
+
template = configured_template(project, config)
|
|
1449
|
+
csl = configured_csl(project, config, bibliography)
|
|
1450
|
+
dependencies = build_dependencies(project, metadata, source_paths, markdown, template, csl)
|
|
1451
|
+
fingerprint = dependency_fingerprint(dependencies, {
|
|
1452
|
+
"release_channel": str(metadata["release-channel"]),
|
|
1453
|
+
"release_selector": str(metadata["release-selector"]),
|
|
1454
|
+
})
|
|
1455
|
+
metadata["trailer-id"] = fingerprint[:32]
|
|
1456
|
+
return metadata, source_paths, markdown, template, bibliography, csl, dependencies, fingerprint
|
|
1457
|
+
|
|
1458
|
+
|
|
1459
|
+
def paths_in_build_dir(paths: dict[str, Path], build_dir: Path) -> dict[str, Path]:
|
|
1460
|
+
staged = dict(paths)
|
|
1461
|
+
staged["build_dir"] = build_dir
|
|
1462
|
+
for key in ["pdf", "cover", "source", "metadata", "bibliography", "manifest"]:
|
|
1463
|
+
staged[key] = build_dir / paths[key].name
|
|
1464
|
+
return staged
|
|
1465
|
+
|
|
1466
|
+
|
|
1467
|
+
def run_command(command: list[str], project: Path) -> None:
|
|
1468
|
+
environment = os.environ.copy()
|
|
1469
|
+
environment.update({"SOURCE_DATE_EPOCH": REPRODUCIBLE_BUILD_EPOCH, "FORCE_SOURCE_DATE": "1", "TZ": "UTC"})
|
|
1470
|
+
completed = subprocess.run(
|
|
1471
|
+
command,
|
|
1472
|
+
cwd=project,
|
|
1473
|
+
env=environment,
|
|
1474
|
+
text=True,
|
|
1475
|
+
stdout=subprocess.PIPE,
|
|
1476
|
+
stderr=subprocess.PIPE,
|
|
1477
|
+
check=False,
|
|
1478
|
+
)
|
|
1479
|
+
if completed.returncode != 0:
|
|
1480
|
+
detail = completed.stderr.strip() or completed.stdout.strip()
|
|
1481
|
+
raise ManualPdfError(f"Command failed ({completed.returncode}): {' '.join(command)}\n{detail}")
|
|
1482
|
+
|
|
1483
|
+
|
|
1484
|
+
def build_language(project: Path, config: dict[str, Any], lang: str) -> dict[str, Any]:
|
|
1485
|
+
paths = artifact_paths(project, config, lang)
|
|
1486
|
+
paths["build_dir"].parent.mkdir(parents=True, exist_ok=True)
|
|
1487
|
+
paths["manifest"].unlink(missing_ok=True)
|
|
1488
|
+
metadata, source_paths, markdown, template, bibliography, csl, dependencies, fingerprint = prepare_build(project, config, lang)
|
|
1489
|
+
|
|
1490
|
+
with tempfile.TemporaryDirectory(prefix=f".{lang}-", dir=paths["build_dir"].parent) as temporary:
|
|
1491
|
+
staged = paths_in_build_dir(paths, Path(temporary))
|
|
1492
|
+
staged["source"].write_text(markdown, encoding="utf-8")
|
|
1493
|
+
clean_bibliography(bibliography, staged["bibliography"])
|
|
1494
|
+
if bibliography:
|
|
1495
|
+
metadata.update(extract_bibliography_metadata(bibliography, staged["bibliography"], project))
|
|
1496
|
+
staged["metadata"].write_text(yaml.safe_dump(metadata, allow_unicode=True, sort_keys=False), encoding="utf-8")
|
|
1497
|
+
command = [
|
|
1498
|
+
"pandoc",
|
|
1499
|
+
str(staged["source"]),
|
|
1500
|
+
"--from=markdown+fenced_divs+pipe_tables+link_attributes",
|
|
1501
|
+
"--standalone",
|
|
1502
|
+
"--top-level-division=chapter",
|
|
1503
|
+
"--number-sections",
|
|
1504
|
+
f"--metadata-file={staged['metadata']}",
|
|
1505
|
+
f"--template={template}",
|
|
1506
|
+
"--pdf-engine=xelatex",
|
|
1507
|
+
f"--resource-path={project}:{staged['build_dir']}",
|
|
1508
|
+
f"--output={staged['pdf']}",
|
|
1509
|
+
]
|
|
1510
|
+
if bibliography:
|
|
1511
|
+
command.extend(["--citeproc", f"--bibliography={staged['bibliography'].with_suffix('.json')}"])
|
|
1512
|
+
if csl:
|
|
1513
|
+
command.append(f"--csl={csl}")
|
|
1514
|
+
command.append(f"--lua-filter={DEFAULT_BIBLIOGRAPHY_FILTER}")
|
|
1515
|
+
command.append(f"--lua-filter={DEFAULT_CODE_BLOCK_FILTER}")
|
|
1516
|
+
command.append(f"--lua-filter={DEFAULT_FIGURE_FILTER}")
|
|
1517
|
+
run_command(command, project)
|
|
1518
|
+
normalized_pdf = staged["pdf"].with_suffix(".normalized.pdf")
|
|
1519
|
+
run_command(
|
|
1520
|
+
[
|
|
1521
|
+
"qpdf",
|
|
1522
|
+
"--deterministic-id",
|
|
1523
|
+
"--object-streams=generate",
|
|
1524
|
+
"--recompress-flate",
|
|
1525
|
+
"--compression-level=9",
|
|
1526
|
+
str(staged["pdf"]),
|
|
1527
|
+
str(normalized_pdf),
|
|
1528
|
+
],
|
|
1529
|
+
project,
|
|
1530
|
+
)
|
|
1531
|
+
os.replace(normalized_pdf, staged["pdf"])
|
|
1532
|
+
cover_prefix = staged["cover"].with_suffix("")
|
|
1533
|
+
run_command(["pdftoppm", "-f", "1", "-singlefile", "-png", "-r", "150", str(staged["pdf"]), str(cover_prefix)], project)
|
|
1534
|
+
manifest = {
|
|
1535
|
+
"language": lang,
|
|
1536
|
+
"built_at": dt.datetime.now(dt.timezone.utc).isoformat(),
|
|
1537
|
+
"draft": metadata["draft"],
|
|
1538
|
+
"release_selector": metadata["release-selector"],
|
|
1539
|
+
"release_channel": metadata["release-channel"],
|
|
1540
|
+
"fingerprint": fingerprint,
|
|
1541
|
+
"dependencies": [label for label, _ in dependencies],
|
|
1542
|
+
"source_files": [str(path.relative_to(project)) for path in source_paths],
|
|
1543
|
+
"pdf": str(paths["pdf"].relative_to(project)),
|
|
1544
|
+
"cover": str(paths["cover"].relative_to(project)),
|
|
1545
|
+
"public_pdf": str(paths["public_pdf"].relative_to(project)),
|
|
1546
|
+
"public_cover": str(paths["public_cover"].relative_to(project)),
|
|
1547
|
+
"artifacts": {
|
|
1548
|
+
"pdf": file_signature(staged["pdf"]),
|
|
1549
|
+
"cover": file_signature(staged["cover"]),
|
|
1550
|
+
},
|
|
1551
|
+
}
|
|
1552
|
+
staged["manifest"].write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
1553
|
+
previous: Path | None = None
|
|
1554
|
+
if paths["build_dir"].exists():
|
|
1555
|
+
previous = Path(tempfile.mkdtemp(prefix=f".{lang}-previous-", dir=paths["build_dir"].parent))
|
|
1556
|
+
previous.rmdir()
|
|
1557
|
+
os.replace(paths["build_dir"], previous)
|
|
1558
|
+
try:
|
|
1559
|
+
os.replace(staged["build_dir"], paths["build_dir"])
|
|
1560
|
+
except OSError:
|
|
1561
|
+
if previous and previous.exists():
|
|
1562
|
+
os.replace(previous, paths["build_dir"])
|
|
1563
|
+
raise
|
|
1564
|
+
if previous and previous.exists():
|
|
1565
|
+
shutil.rmtree(previous)
|
|
1566
|
+
return manifest
|
|
1567
|
+
|
|
1568
|
+
|
|
1569
|
+
def status_language(project: Path, config: dict[str, Any], lang: str) -> dict[str, Any]:
|
|
1570
|
+
paths = artifact_paths(project, config, lang)
|
|
1571
|
+
manifest: dict[str, Any] = {}
|
|
1572
|
+
try:
|
|
1573
|
+
_, source_files, _, _, _, _, _, fingerprint = prepare_build(project, config, lang)
|
|
1574
|
+
source_lang = lang
|
|
1575
|
+
error = ""
|
|
1576
|
+
except ManualPdfError as exc:
|
|
1577
|
+
source_lang = lang
|
|
1578
|
+
source_files = []
|
|
1579
|
+
fingerprint = ""
|
|
1580
|
+
error = str(exc)
|
|
1581
|
+
if paths["manifest"].is_file():
|
|
1582
|
+
try:
|
|
1583
|
+
loaded = json.loads(paths["manifest"].read_text(encoding="utf-8"))
|
|
1584
|
+
manifest = loaded if isinstance(loaded, dict) else {}
|
|
1585
|
+
except (OSError, json.JSONDecodeError):
|
|
1586
|
+
manifest = {}
|
|
1587
|
+
expected = {
|
|
1588
|
+
"language": lang,
|
|
1589
|
+
"fingerprint": fingerprint,
|
|
1590
|
+
"pdf": str(paths["pdf"].relative_to(project)),
|
|
1591
|
+
"cover": str(paths["cover"].relative_to(project)),
|
|
1592
|
+
"public_pdf": str(paths["public_pdf"].relative_to(project)),
|
|
1593
|
+
"public_cover": str(paths["public_cover"].relative_to(project)),
|
|
1594
|
+
"release_selector": release_metadata()["release-selector"],
|
|
1595
|
+
"release_channel": release_metadata()["release-channel"],
|
|
1596
|
+
}
|
|
1597
|
+
manifest_valid = bool(manifest) and all(manifest.get(key) == value for key, value in expected.items())
|
|
1598
|
+
generated_pdf_exists = paths["pdf"].is_file()
|
|
1599
|
+
generated_cover_exists = paths["cover"].is_file()
|
|
1600
|
+
expected_artifacts = manifest.get("artifacts") if isinstance(manifest.get("artifacts"), dict) else {}
|
|
1601
|
+
artifacts_valid = (
|
|
1602
|
+
generated_pdf_exists
|
|
1603
|
+
and generated_cover_exists
|
|
1604
|
+
and expected_artifacts.get("pdf") == file_signature(paths["pdf"])
|
|
1605
|
+
and expected_artifacts.get("cover") == file_signature(paths["cover"])
|
|
1606
|
+
)
|
|
1607
|
+
ready = not error and manifest_valid and artifacts_valid
|
|
1608
|
+
published_current = (
|
|
1609
|
+
ready
|
|
1610
|
+
and paths["public_pdf"].is_file()
|
|
1611
|
+
and paths["public_cover"].is_file()
|
|
1612
|
+
and paths["public_pdf"].read_bytes() == paths["pdf"].read_bytes()
|
|
1613
|
+
and paths["public_cover"].read_bytes() == paths["cover"].read_bytes()
|
|
1614
|
+
)
|
|
1615
|
+
return {
|
|
1616
|
+
"language": lang,
|
|
1617
|
+
"source_language": source_lang,
|
|
1618
|
+
"source_count": len(source_files),
|
|
1619
|
+
"generated_pdf": str(paths["pdf"].relative_to(project)),
|
|
1620
|
+
"generated_pdf_exists": generated_pdf_exists,
|
|
1621
|
+
"generated_cover": str(paths["cover"].relative_to(project)),
|
|
1622
|
+
"generated_cover_exists": generated_cover_exists,
|
|
1623
|
+
"published_pdf": str(paths["public_pdf"].relative_to(project)),
|
|
1624
|
+
"published_pdf_exists": paths["public_pdf"].is_file(),
|
|
1625
|
+
"published_cover": str(paths["public_cover"].relative_to(project)),
|
|
1626
|
+
"published_cover_exists": paths["public_cover"].is_file(),
|
|
1627
|
+
"manifest_valid": manifest_valid,
|
|
1628
|
+
"artifacts_valid": artifacts_valid,
|
|
1629
|
+
"fresh": ready,
|
|
1630
|
+
"ready_to_publish": ready,
|
|
1631
|
+
"published_current": published_current,
|
|
1632
|
+
"release_selector": expected["release_selector"],
|
|
1633
|
+
"release_channel": expected["release_channel"],
|
|
1634
|
+
"error": error,
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
|
|
1638
|
+
def stage_copy(source: Path, destination: Path, suffix: str) -> Path:
|
|
1639
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
1640
|
+
descriptor, raw_path = tempfile.mkstemp(prefix=f".{destination.name}.", suffix=suffix, dir=destination.parent)
|
|
1641
|
+
temporary = Path(raw_path)
|
|
1642
|
+
try:
|
|
1643
|
+
with os.fdopen(descriptor, "wb") as target, source.open("rb") as origin:
|
|
1644
|
+
shutil.copyfileobj(origin, target)
|
|
1645
|
+
shutil.copystat(source, temporary)
|
|
1646
|
+
return temporary
|
|
1647
|
+
except BaseException:
|
|
1648
|
+
temporary.unlink(missing_ok=True)
|
|
1649
|
+
raise
|
|
1650
|
+
|
|
1651
|
+
|
|
1652
|
+
def publish_language(project: Path, config: dict[str, Any], lang: str, dry_run: bool) -> dict[str, Any]:
|
|
1653
|
+
paths = artifact_paths(project, config, lang)
|
|
1654
|
+
status = status_language(project, config, lang)
|
|
1655
|
+
if not status["ready_to_publish"]:
|
|
1656
|
+
raise ManualPdfError(f"Build language '{lang}' successfully after the latest source or configuration change before publishing it.")
|
|
1657
|
+
for destination in [paths["public_pdf"], paths["public_cover"]]:
|
|
1658
|
+
if destination.exists() and not destination.is_file():
|
|
1659
|
+
raise ManualPdfError(f"Publication destination is not a regular file: {destination.relative_to(project)}")
|
|
1660
|
+
operations = [
|
|
1661
|
+
{"source": str(paths["pdf"].relative_to(project)), "destination": str(paths["public_pdf"].relative_to(project))},
|
|
1662
|
+
{"source": str(paths["cover"].relative_to(project)), "destination": str(paths["public_cover"].relative_to(project))},
|
|
1663
|
+
]
|
|
1664
|
+
if not dry_run:
|
|
1665
|
+
pairs = [(paths["pdf"], paths["public_pdf"]), (paths["cover"], paths["public_cover"])]
|
|
1666
|
+
staged: list[tuple[Path, Path]] = []
|
|
1667
|
+
try:
|
|
1668
|
+
for source, destination in pairs:
|
|
1669
|
+
staged.append((stage_copy(source, destination, ".tmp"), destination))
|
|
1670
|
+
except BaseException:
|
|
1671
|
+
for temporary, _ in staged:
|
|
1672
|
+
temporary.unlink(missing_ok=True)
|
|
1673
|
+
raise
|
|
1674
|
+
backups: list[tuple[Path | None, Path]] = []
|
|
1675
|
+
try:
|
|
1676
|
+
for _, destination in staged:
|
|
1677
|
+
backup = stage_copy(destination, destination, ".backup") if destination.is_file() else None
|
|
1678
|
+
backups.append((backup, destination))
|
|
1679
|
+
for temporary, destination in staged:
|
|
1680
|
+
os.replace(temporary, destination)
|
|
1681
|
+
except BaseException:
|
|
1682
|
+
for backup, destination in backups:
|
|
1683
|
+
if backup and backup.exists():
|
|
1684
|
+
os.replace(backup, destination)
|
|
1685
|
+
else:
|
|
1686
|
+
destination.unlink(missing_ok=True)
|
|
1687
|
+
raise
|
|
1688
|
+
finally:
|
|
1689
|
+
for temporary, _ in staged:
|
|
1690
|
+
temporary.unlink(missing_ok=True)
|
|
1691
|
+
for backup, _ in backups:
|
|
1692
|
+
if backup:
|
|
1693
|
+
backup.unlink(missing_ok=True)
|
|
1694
|
+
return {"language": lang, "dry_run": dry_run, "operations": operations}
|
|
1695
|
+
|
|
1696
|
+
|
|
1697
|
+
def sync_language(project: Path, config: dict[str, Any], lang: str) -> dict[str, Any]:
|
|
1698
|
+
status = status_language(project, config, lang)
|
|
1699
|
+
built = False
|
|
1700
|
+
published = False
|
|
1701
|
+
if not status["ready_to_publish"]:
|
|
1702
|
+
build_language(project, config, lang)
|
|
1703
|
+
built = True
|
|
1704
|
+
status = status_language(project, config, lang)
|
|
1705
|
+
if not status["published_current"]:
|
|
1706
|
+
publish_language(project, config, lang, False)
|
|
1707
|
+
published = True
|
|
1708
|
+
return {
|
|
1709
|
+
"language": lang,
|
|
1710
|
+
"state": "updated" if built or published else "current",
|
|
1711
|
+
"built": built,
|
|
1712
|
+
"published": published,
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
|
|
1716
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1717
|
+
parser = argparse.ArgumentParser(prog="unaltraweb-manual-pdf")
|
|
1718
|
+
parser.add_argument("command", choices=["status", "check", "build", "publish", "sync", "public-paths"])
|
|
1719
|
+
parser.add_argument("--project", default=".")
|
|
1720
|
+
parser.add_argument("--language", default="")
|
|
1721
|
+
parser.add_argument("--dry-run", action="store_true")
|
|
1722
|
+
args = parser.parse_args(argv)
|
|
1723
|
+
|
|
1724
|
+
project = Path(args.project).expanduser().resolve()
|
|
1725
|
+
try:
|
|
1726
|
+
config = read_yaml(project / "_config.yml")
|
|
1727
|
+
pdf = nested(config, "unaltraweb", "manual", "pdf")
|
|
1728
|
+
enabled = bool(pdf.get("enabled", False))
|
|
1729
|
+
configured_languages = language_list(config, pdf)
|
|
1730
|
+
configured_plan = artifact_plan(project, config, configured_languages) if enabled else []
|
|
1731
|
+
if args.command == "public-paths":
|
|
1732
|
+
languages = language_list(config, pdf, args.language) if enabled else []
|
|
1733
|
+
selected = set(languages)
|
|
1734
|
+
paths = [paths for language, paths in configured_plan if language in selected]
|
|
1735
|
+
payload = {
|
|
1736
|
+
"project": str(project),
|
|
1737
|
+
"ok": True,
|
|
1738
|
+
"enabled": enabled,
|
|
1739
|
+
"paths": sorted({
|
|
1740
|
+
str(paths_for_language[key].relative_to(project))
|
|
1741
|
+
for paths_for_language in paths
|
|
1742
|
+
for key in ["public_pdf", "public_cover"]
|
|
1743
|
+
}),
|
|
1744
|
+
}
|
|
1745
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
1746
|
+
return 0
|
|
1747
|
+
languages = language_list(config, pdf, args.language)
|
|
1748
|
+
if args.command == "sync" and not enabled:
|
|
1749
|
+
print(json.dumps({"project": str(project), "ok": True, "enabled": False, "skipped": True}, ensure_ascii=False, indent=2))
|
|
1750
|
+
return 0
|
|
1751
|
+
if args.command not in {"status", "check"} and not enabled:
|
|
1752
|
+
raise ManualPdfError("Manual PDF generation is disabled in unaltraweb.manual.pdf.enabled.")
|
|
1753
|
+
if args.command in {"status", "check"}:
|
|
1754
|
+
payload: dict[str, Any] = {
|
|
1755
|
+
"project": str(project),
|
|
1756
|
+
"enabled": enabled,
|
|
1757
|
+
"languages": [status_language(project, config, lang) for lang in languages],
|
|
1758
|
+
}
|
|
1759
|
+
payload["configuration_ok"] = payload["enabled"] and all(not item["error"] for item in payload["languages"])
|
|
1760
|
+
payload["ready_to_publish"] = payload["configuration_ok"] and all(item["ready_to_publish"] for item in payload["languages"])
|
|
1761
|
+
payload["published_current"] = payload["configuration_ok"] and all(item["published_current"] for item in payload["languages"])
|
|
1762
|
+
payload["ok"] = payload["published_current"] if args.command == "check" else payload["ready_to_publish"]
|
|
1763
|
+
elif args.command == "build":
|
|
1764
|
+
results = [build_language(project, config, lang) for lang in languages]
|
|
1765
|
+
payload = {"project": str(project), "ok": True, "built": results}
|
|
1766
|
+
elif args.command == "publish":
|
|
1767
|
+
results = [publish_language(project, config, lang, args.dry_run) for lang in languages]
|
|
1768
|
+
payload = {"project": str(project), "ok": True, "dry_run": args.dry_run, "published": results}
|
|
1769
|
+
else:
|
|
1770
|
+
results = [sync_language(project, config, lang) for lang in languages]
|
|
1771
|
+
payload = {"project": str(project), "ok": True, "synced": results}
|
|
1772
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
1773
|
+
return 0 if payload["ok"] else 1
|
|
1774
|
+
except (ManualPdfError, OSError, yaml.YAMLError) as exc:
|
|
1775
|
+
print(json.dumps({"project": str(project), "ok": False, "error": str(exc)}, ensure_ascii=False, indent=2), file=sys.stderr)
|
|
1776
|
+
return 2
|
|
1777
|
+
|
|
1778
|
+
|
|
1779
|
+
if __name__ == "__main__":
|
|
1780
|
+
raise SystemExit(main())
|