pandorga 1.2.1
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/CHANGELOG.md +77 -0
- data/DESIGN.md +1434 -0
- data/LICENSE +21 -0
- data/README.md +81 -0
- data/_data/themes/dark.yml +97 -0
- data/_data/themes/light.yml +120 -0
- data/_data/themes/shared.yml +37 -0
- data/_data/translations.yml +85 -0
- data/_includes/chord-tab.html +38 -0
- data/_includes/content-runtime/00-config.html +22 -0
- data/_includes/content-runtime/10-utils.html +176 -0
- data/_includes/content-runtime/20-markdown.html +264 -0
- data/_includes/content-runtime/25-code-blocks.html +315 -0
- data/_includes/content-runtime/30-includes-bib.html +190 -0
- data/_includes/content-runtime/30-includes-xcite.html +122 -0
- data/_includes/content-runtime/30-includes.html +365 -0
- data/_includes/content-runtime/40-listing.html +225 -0
- data/_includes/content-runtime/41-listing-helpers.html +71 -0
- data/_includes/content-runtime/42-listing-filter.html +208 -0
- data/_includes/content-runtime/43-blocks.html +379 -0
- data/_includes/content-runtime/50-detail.html +861 -0
- data/_includes/content-runtime/55-toc.html +176 -0
- data/_includes/content-runtime/60-fragments.html +567 -0
- data/_includes/content-runtime/90-bootstrap.html +76 -0
- data/_includes/cut-in.html +10 -0
- data/_includes/cv/cv-box.html +18 -0
- data/_includes/dict-card.html +24 -0
- data/_includes/figure.html +36 -0
- data/_includes/home/band-index.html +43 -0
- data/_includes/home/band-listing.html +49 -0
- data/_includes/mathjax.html +10 -0
- data/_includes/ongoing-product.html +1 -0
- data/_includes/page/content-runtime.html +20 -0
- data/_includes/page/date_evolution.html +19 -0
- data/_includes/page/figure-lightbox.html +102 -0
- data/_includes/page/formatted_date.html +15 -0
- data/_includes/page/hero-default.html +18 -0
- data/_includes/page/listing-runtime-attrs.html +8 -0
- data/_includes/page/mermaid.html +101 -0
- data/_includes/page/network-graph.html +1253 -0
- data/_includes/page/page-header.html +41 -0
- data/_includes/page/professional-connections.html +2 -0
- data/_includes/page/site-footer.html +101 -0
- data/_includes/page/timeline-section.html +868 -0
- data/_includes/plotly.html +22 -0
- data/_includes/theme/dev-reexport.html +100 -0
- data/_includes/theme/favicons.html +4 -0
- data/_includes/theme/font-loader.html +55 -0
- data/_includes/theme/shell-theme-boot.html +115 -0
- data/_includes/theme/site-logo.html +6 -0
- data/_includes/theme/tailwind-theme-colors.json.liquid +65 -0
- data/_includes/theme/tailwind-theme-fonts.json.liquid +6 -0
- data/_includes/theme/theme-context.liquid +20 -0
- data/_includes/theme/theme-toggle.html +94 -0
- data/_includes/theme/theme-vars-block.liquid +161 -0
- data/_includes/theme/theme-vars.html +16 -0
- data/_includes/transcription.html +80 -0
- data/_includes/tweet.html +22 -0
- data/_includes/video.html +11 -0
- data/_includes/xcite.html +3 -0
- data/_includes/youtube-short.html +26 -0
- data/_includes/youtube.html +12 -0
- data/_layouts/article.html +27 -0
- data/_layouts/articles.html +534 -0
- data/_layouts/bibliography.html +417 -0
- data/_layouts/blog.html +544 -0
- data/_layouts/cv.html +393 -0
- data/_layouts/home.html +319 -0
- data/_layouts/network.html +9 -0
- data/_layouts/pandorga/articles.html +4 -0
- data/_layouts/pandorga/bibliography.html +4 -0
- data/_layouts/pandorga/blog.html +4 -0
- data/_layouts/pandorga/cv.html +4 -0
- data/_layouts/pandorga/media.html +4 -0
- data/_layouts/pandorga/network.html +4 -0
- data/_layouts/pandorga/portfolio.html +4 -0
- data/_layouts/post.html +7 -0
- data/_layouts/product.html +5 -0
- data/_layouts/project.html +27 -0
- data/_layouts/projects.html +534 -0
- data/_layouts/resources.html +387 -0
- data/_layouts/shell.html +203 -0
- data/_layouts/text.html +246 -0
- data/_plugins/bibliography_artifact.rb +57 -0
- data/_plugins/content_backend.rb +37 -0
- data/_plugins/content_collections.rb +51 -0
- data/_plugins/content_detail_paths.rb +58 -0
- data/_plugins/content_headers.rb +73 -0
- data/_plugins/content_keys.rb +310 -0
- data/_plugins/content_local_serve.rb +168 -0
- data/_plugins/content_site_adapter.rb +159 -0
- data/_plugins/content_validators_hook.rb +20 -0
- data/_plugins/content_writing_index.rb +52 -0
- data/_plugins/content_writing_paths.rb +55 -0
- data/_plugins/force_utf8_encoding.rb +13 -0
- data/_plugins/git_chronology.rb +211 -0
- data/_plugins/git_metadata.rb +43 -0
- data/_plugins/job_sections.rb +25 -0
- data/_plugins/lib/content_validators.rb +27 -0
- data/_plugins/lib/resource_validator.rb +56 -0
- data/_plugins/lib/tag_validator.rb +153 -0
- data/_plugins/lib/text_excerpt.rb +56 -0
- data/_plugins/lib/writing_entry.rb +107 -0
- data/_plugins/local_detail_rewrites.rb +194 -0
- data/_plugins/timeline.rb +507 -0
- data/_plugins/writing_slug_limits.rb +70 -0
- data/assets/css/article.css +1330 -0
- data/assets/css/base.css +1432 -0
- data/assets/css/cv.css +13 -0
- data/assets/css/global-link.css +94 -0
- data/assets/css/ledger/bibliography.css +307 -0
- data/assets/css/ledger/chrome.css +118 -0
- data/assets/css/ledger/cv.css +836 -0
- data/assets/css/ledger/detail.css +1270 -0
- data/assets/css/ledger/footer.css +298 -0
- data/assets/css/ledger/home.css +1490 -0
- data/assets/css/ledger/network.css +106 -0
- data/assets/css/ledger/projects.css +716 -0
- data/assets/css/ledger/rascunhos.css +379 -0
- data/assets/css/ledger/sources.css +327 -0
- data/assets/css/ledger/writing.css +754 -0
- data/assets/tailwind-play.js +15 -0
- data/examples/full/Gemfile +7 -0
- data/examples/full/_config.yml +92 -0
- data/examples/full/_redirects +3 -0
- data/examples/full/content/collections/articles/2026-01-01-welcome.md +9 -0
- data/examples/full/content/collections/data/contacts.yml +3 -0
- data/examples/full/content/collections/data/education.yml +4 -0
- data/examples/full/content/collections/jobs/research-fellow.md +12 -0
- data/examples/full/content/collections/posts/2026-01-02-note.md +9 -0
- data/examples/full/content/collections/products/ledger-tool.md +6 -0
- data/examples/full/content/collections/projects/demo-kit.md +10 -0
- data/examples/full/content/collections/resources/style-guide.md +7 -0
- data/examples/full/content/pages/cv/summary.md +1 -0
- data/examples/full/content/pages/headers.yml +20 -0
- data/examples/full/index.html +4 -0
- data/examples/minimal/Gemfile +7 -0
- data/examples/minimal/_config.yml +47 -0
- data/examples/minimal/_redirects +3 -0
- data/examples/minimal/content/collections/articles/2026-01-01-hello.md +10 -0
- data/examples/minimal/content/collections/articles/2026-01-02-second.md +9 -0
- data/examples/minimal/content/pages/headers.yml +6 -0
- data/examples/minimal/index.html +4 -0
- data/exe/pandorga +7 -0
- data/functions/api/studio/[[path]].js +562 -0
- data/functions/api/studio/_lib/auth.js +131 -0
- data/functions/api/studio/_lib/blocks.js +156 -0
- data/functions/api/studio/_lib/gemini.js +62 -0
- data/functions/api/studio/_lib/github.js +278 -0
- data/functions/api/studio/_lib/guidelines.js +143 -0
- data/functions/api/studio/_lib/paths.js +76 -0
- data/functions/api/studio/_lib/prompts.js +165 -0
- data/functions/api/studio/_lib/rate-limit.js +40 -0
- data/functions/api/studio/_lib/rewrite.js +136 -0
- data/functions/api/studio/_lib/sanitize.js +143 -0
- data/lib/pandorga/cli.rb +66 -0
- data/lib/pandorga/commands/doctor.rb +66 -0
- data/lib/pandorga/commands/export.rb +30 -0
- data/lib/pandorga/commands/install_functions.rb +41 -0
- data/lib/pandorga/commands/new.rb +113 -0
- data/lib/pandorga/commands/publish.rb +53 -0
- data/lib/pandorga/commands/serve.rb +34 -0
- data/lib/pandorga/commands/studio_schema.rb +309 -0
- data/lib/pandorga/jekyll/generator.rb +74 -0
- data/lib/pandorga/jekyll/plugins/bibliography_artifact.rb +57 -0
- data/lib/pandorga/jekyll/plugins/content_backend.rb +37 -0
- data/lib/pandorga/jekyll/plugins/content_collections.rb +51 -0
- data/lib/pandorga/jekyll/plugins/content_detail_paths.rb +58 -0
- data/lib/pandorga/jekyll/plugins/content_headers.rb +73 -0
- data/lib/pandorga/jekyll/plugins/content_keys.rb +310 -0
- data/lib/pandorga/jekyll/plugins/content_local_serve.rb +168 -0
- data/lib/pandorga/jekyll/plugins/content_site_adapter.rb +159 -0
- data/lib/pandorga/jekyll/plugins/content_validators_hook.rb +20 -0
- data/lib/pandorga/jekyll/plugins/content_writing_index.rb +52 -0
- data/lib/pandorga/jekyll/plugins/content_writing_paths.rb +55 -0
- data/lib/pandorga/jekyll/plugins/force_utf8_encoding.rb +13 -0
- data/lib/pandorga/jekyll/plugins/git_chronology.rb +211 -0
- data/lib/pandorga/jekyll/plugins/git_metadata.rb +43 -0
- data/lib/pandorga/jekyll/plugins/job_sections.rb +25 -0
- data/lib/pandorga/jekyll/plugins/lib/content_validators.rb +27 -0
- data/lib/pandorga/jekyll/plugins/lib/resource_validator.rb +56 -0
- data/lib/pandorga/jekyll/plugins/lib/tag_validator.rb +153 -0
- data/lib/pandorga/jekyll/plugins/lib/text_excerpt.rb +56 -0
- data/lib/pandorga/jekyll/plugins/lib/writing_entry.rb +107 -0
- data/lib/pandorga/jekyll/plugins/local_detail_rewrites.rb +194 -0
- data/lib/pandorga/jekyll/plugins/timeline.rb +507 -0
- data/lib/pandorga/jekyll/plugins/writing_slug_limits.rb +70 -0
- data/lib/pandorga/jekyll.rb +15 -0
- data/lib/pandorga/object_store_env.rb +51 -0
- data/lib/pandorga/registry/schema.yml +115 -0
- data/lib/pandorga/registry.rb +279 -0
- data/lib/pandorga/version.rb +5 -0
- data/lib/pandorga.rb +19 -0
- data/scripts/cdn-allowlist.json +19 -0
- data/scripts/content/backfill-content-keys.rb +99 -0
- data/scripts/content/band_art.rb +362 -0
- data/scripts/content/export-content-json.rb +741 -0
- data/scripts/content/flatten-ansi-capture.mjs +153 -0
- data/scripts/content/hero_portraits.rb +28 -0
- data/scripts/content/publish-content-to-object-store.sh +78 -0
- data/scripts/content/publish-content-to-r2.sh +5 -0
- data/scripts/content/sync-content-json-to-r2.rb +167 -0
- data/scripts/content/sync-content-redirects.rb +12 -0
- data/scripts/generate-configuration-doc.rb +127 -0
- data/scripts/lib/content_license.rb +50 -0
- data/scripts/lib/content_storage_config.rb +70 -0
- data/scripts/lib/normalize-display-math.mjs +52 -0
- data/scripts/lib/r2_json_sync.rb +76 -0
- data/scripts/lib/withdrawn_public_objects.rb +23 -0
- data/scripts/lib/writing_index_contract.rb +61 -0
- data/scripts/pdf/md-to-pdf.mjs +1400 -0
- data/scripts/pdf/md-to-pdf.sh +27 -0
- data/scripts/test/fixtures/dual-template/_config.yml +32 -0
- data/scripts/test/fixtures/dual-template/index.html +4 -0
- data/scripts/test/render-parity/article-note.json +5 -0
- data/scripts/test/render-parity/blockquote.json +5 -0
- data/scripts/test/render-parity/blocks/ansi-basic.json +7 -0
- data/scripts/test/render-parity/blocks/ansi-bold-is-bright.json +7 -0
- data/scripts/test/render-parity/blocks/ansi-drops-cursor-moves.json +7 -0
- data/scripts/test/render-parity/blocks/ansi-escapes-markup.json +7 -0
- data/scripts/test/render-parity/blocks/ansi-extended-colour.json +7 -0
- data/scripts/test/render-parity/blocks/ansi-real-control-byte.json +7 -0
- data/scripts/test/render-parity/blocks/archive-card-date-once.json +19 -0
- data/scripts/test/render-parity/blocks/archive-card-empty-foot.json +7 -0
- data/scripts/test/render-parity/blocks/archive-card-footer-dates.json +22 -0
- data/scripts/test/render-parity/blocks/archive-card-meta-parts.json +26 -0
- data/scripts/test/render-parity/blocks/archive-card.json +32 -0
- data/scripts/test/render-parity/blocks/cta-link.json +13 -0
- data/scripts/test/render-parity/blocks/lang-pill-pt.json +8 -0
- data/scripts/test/render-parity/blocks/lang-pill.json +8 -0
- data/scripts/test/render-parity/blocks/ledger-row-meta-parts.json +29 -0
- data/scripts/test/render-parity/blocks/ledger-row.json +35 -0
- data/scripts/test/render-parity/blocks/meta-strip.json +27 -0
- data/scripts/test/render-parity/blocks/poster-media-image.json +8 -0
- data/scripts/test/render-parity/blocks/poster-media-video.json +9 -0
- data/scripts/test/render-parity/blocks/section-banner.json +13 -0
- data/scripts/test/render-parity/blocks/system-card-custom-meta.json +22 -0
- data/scripts/test/render-parity/blocks/system-card.json +48 -0
- data/scripts/test/render-parity/blocks/tag-chips-links.json +12 -0
- data/scripts/test/render-parity/blocks/tag-chips.json +9 -0
- data/scripts/test/render-parity/cut-in-blank-line.json +9 -0
- data/scripts/test/render-parity/cut-in-right.json +9 -0
- data/scripts/test/render-parity/cut-in.json +8 -0
- data/scripts/test/render-parity/figure.json +5 -0
- data/scripts/test/render-parity/load-runtime.mjs +337 -0
- data/scripts/test/render-parity/ongoing-product.json +5 -0
- data/scripts/test/render-parity/plotly.json +5 -0
- data/scripts/test/render-parity/poem.json +5 -0
- data/scripts/test/render-parity/run-absent-field-checks.mjs +232 -0
- data/scripts/test/render-parity/run-block-fixture.mjs +86 -0
- data/scripts/test/render-parity/run-detail-parity-checks.mjs +123 -0
- data/scripts/test/render-parity/run-field-flow-checks.mjs +227 -0
- data/scripts/test/render-parity/run-fixture.mjs +159 -0
- data/scripts/test/render-parity/selectors.mjs +43 -0
- data/scripts/test/render-parity/video.json +5 -0
- data/scripts/test/render-parity/youtube-short.json +10 -0
- data/scripts/test/render-parity/youtube.json +8 -0
- data/scripts/test/test-absent-field-absent-element.rb +43 -0
- data/scripts/test/test-cdn-pins.rb +44 -0
- data/scripts/test/test-cloudflare-watch-paths-doc.rb +31 -0
- data/scripts/test/test-configuration-doc.rb +36 -0
- data/scripts/test/test-content-detail-urls.rb +346 -0
- data/scripts/test/test-content-keys.rb +110 -0
- data/scripts/test/test-content-license.rb +45 -0
- data/scripts/test/test-content-render-parity.rb +38 -0
- data/scripts/test/test-content-validators.rb +12 -0
- data/scripts/test/test-css-structure.rb +72 -0
- data/scripts/test/test-cv-export-contract.rb +158 -0
- data/scripts/test/test-detail-render-parity.rb +40 -0
- data/scripts/test/test-dev-reexport.rb +122 -0
- data/scripts/test/test-doctor.rb +39 -0
- data/scripts/test/test-dual-template-instances.rb +87 -0
- data/scripts/test/test-dual-template-runtime.rb +167 -0
- data/scripts/test/test-examples-build.rb +60 -0
- data/scripts/test/test-export-content-json-incremental.rb +138 -0
- data/scripts/test/test-export.rb +53 -0
- data/scripts/test/test-exported-fields-reach-the-view.rb +60 -0
- data/scripts/test/test-form-control-theming.rb +143 -0
- data/scripts/test/test-git-chronology.rb +414 -0
- data/scripts/test/test-hero-portraits.mjs +97 -0
- data/scripts/test/test-hero-portraits.rb +162 -0
- data/scripts/test/test-home-band-art.rb +135 -0
- data/scripts/test/test-install-functions.rb +39 -0
- data/scripts/test/test-language-export.rb +79 -0
- data/scripts/test/test-listing-filter-contract.rb +119 -0
- data/scripts/test/test-math-row-breaks.mjs +79 -0
- data/scripts/test/test-math-row-breaks.rb +18 -0
- data/scripts/test/test-new.rb +113 -0
- data/scripts/test/test-no-personal-data.rb +85 -0
- data/scripts/test/test-public-cv-export.rb +164 -0
- data/scripts/test/test-r2-config-consistency.rb +57 -0
- data/scripts/test/test-r2-json-sync.rb +193 -0
- data/scripts/test/test-registry-home.rb +34 -0
- data/scripts/test/test-runtime-js-syntax.rb +64 -0
- data/scripts/test/test-slim-writing-index.rb +126 -0
- data/scripts/test/test-studio-api-contract.rb +99 -0
- data/scripts/test/test-studio-content-files.rb +79 -0
- data/scripts/test/test-studio-liquid-roundtrip.mjs +179 -0
- data/scripts/test/test-studio-rate-limit.mjs +70 -0
- data/scripts/test/test-studio-rewrite-api-contract.rb +59 -0
- data/scripts/test/test-studio-rewrite-sanitize.mjs +210 -0
- data/scripts/test/test-studio-schema-composition.rb +126 -0
- data/scripts/test/test-studio-slug-config.rb +167 -0
- data/scripts/test/test-template-packages.rb +51 -0
- data/scripts/test/test-text-excerpt.rb +51 -0
- data/scripts/test/test-unwrap-media-src.mjs +39 -0
- data/scripts/test/test-unwrap-media-src.rb +19 -0
- data/scripts/test/test-withdrawn-content-export.rb +69 -0
- data/scripts/test/test-writing-index-export-slugs.rb +84 -0
- data/scripts/test/test-writing-slug-limits.rb +61 -0
- data/scripts/test/test-xcite-chronology.rb +106 -0
- data/scripts/validate.sh +74 -0
- data/scripts/visual/build-cv-pages-json.js +33 -0
- data/scripts/visual/build-site-pages-json.js +94 -0
- data/scripts/visual/publish-visual-inspection-to-r2.sh +42 -0
- data/scripts/visual/take-screenshots.js +88 -0
- data/scripts/visual/visual-inspection-pages.json +113 -0
- data/studio/assets/index-B_j0pMD5.css +1 -0
- data/studio/assets/index-Ch4YEv9g.js +285 -0
- data/studio/index.html +27 -0
- data/studio/schema.yml +869 -0
- data/studio-app/README.md +27 -0
- data/studio-app/index.html +26 -0
- data/studio-app/package-lock.json +2499 -0
- data/studio-app/package.json +26 -0
- data/studio-app/skills-lock.json +47 -0
- data/studio-app/src/api/client.js +71 -0
- data/studio-app/src/editor/body-editor.js +70 -0
- data/studio-app/src/editor/liquid-core.js +382 -0
- data/studio-app/src/editor/liquid.js +77 -0
- data/studio-app/src/lib/document.js +207 -0
- data/studio-app/src/lib/list-controls.js +317 -0
- data/studio-app/src/lib/schema.js +66 -0
- data/studio-app/src/lib/yaml-doc.js +100 -0
- data/studio-app/src/main.js +3693 -0
- data/studio-app/src/media/browser.js +578 -0
- data/studio-app/src/preview/render.js +430 -0
- data/studio-app/src/styles/studio.css +2826 -0
- data/studio-app/vite.config.js +25 -0
- data/templates/articles/README.md +40 -0
- data/templates/articles/fixtures/article.md +9 -0
- data/templates/articles/screenshots/README.md +6 -0
- data/templates/articles/studio.yml +73 -0
- data/templates/articles/template.yml +21 -0
- data/templates/bibliography/README.md +41 -0
- data/templates/bibliography/fixtures/references.json +9 -0
- data/templates/bibliography/screenshots/README.md +6 -0
- data/templates/bibliography/studio.yml +66 -0
- data/templates/bibliography/template.yml +18 -0
- data/templates/blog/README.md +38 -0
- data/templates/blog/fixtures/post.md +9 -0
- data/templates/blog/screenshots/README.md +6 -0
- data/templates/blog/studio.yml +69 -0
- data/templates/blog/template.yml +20 -0
- data/templates/cv/README.md +34 -0
- data/templates/cv/fixtures/job.md +12 -0
- data/templates/cv/screenshots/README.md +6 -0
- data/templates/cv/studio.yml +200 -0
- data/templates/cv/template.yml +25 -0
- data/templates/media/README.md +38 -0
- data/templates/media/fixtures/resource.md +7 -0
- data/templates/media/screenshots/README.md +6 -0
- data/templates/media/studio.yml +58 -0
- data/templates/media/template.yml +17 -0
- data/templates/network/README.md +34 -0
- data/templates/network/fixtures/sources.json +3 -0
- data/templates/network/screenshots/README.md +6 -0
- data/templates/network/studio.yml +28 -0
- data/templates/network/template.yml +10 -0
- data/templates/portfolio/README.md +39 -0
- data/templates/portfolio/fixtures/project.md +10 -0
- data/templates/portfolio/screenshots/README.md +6 -0
- data/templates/portfolio/studio.yml +89 -0
- data/templates/portfolio/template.yml +18 -0
- metadata +505 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "set"
|
|
6
|
+
require "yaml"
|
|
7
|
+
|
|
8
|
+
require_relative "git_chronology"
|
|
9
|
+
require_relative "content_keys"
|
|
10
|
+
require_relative "content_writing_paths"
|
|
11
|
+
require_relative "lib/text_excerpt"
|
|
12
|
+
require_relative "lib/writing_entry"
|
|
13
|
+
|
|
14
|
+
# Indexes articles and posts from /content for timeline and network features
|
|
15
|
+
# without emitting Jekyll collection pages (those load from the content API).
|
|
16
|
+
module Jekyll
|
|
17
|
+
class ContentWritingIndex < Generator
|
|
18
|
+
safe true
|
|
19
|
+
priority :high
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def generate(site)
|
|
23
|
+
return if site.config["shell_content_agnostic"]
|
|
24
|
+
|
|
25
|
+
content_root = File.join(site.source, site.config["content_root"] || "content")
|
|
26
|
+
entries = []
|
|
27
|
+
|
|
28
|
+
if File.directory?(content_root)
|
|
29
|
+
ContentWritingPaths::INDEX_BY_COLLECTION.each do |collection, relative_path|
|
|
30
|
+
directory = File.join(content_root, relative_path)
|
|
31
|
+
next unless File.directory?(directory)
|
|
32
|
+
|
|
33
|
+
Dir.glob(File.join(directory, "*.{md,markdown}")).sort.each do |absolute_path|
|
|
34
|
+
entry = build_entry(absolute_path, collection)
|
|
35
|
+
entries << entry if entry
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
site.data["writing_index"] = entries
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def build_entry(absolute_path, collection)
|
|
46
|
+
repo_relative_path = ContentWritingPaths.repo_relative_for_index_collection(
|
|
47
|
+
collection, File.basename(absolute_path)
|
|
48
|
+
)
|
|
49
|
+
WritingEntry.build(absolute_path, collection, repo_relative_path)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Canonical filesystem locations for articles and posts under content/.
|
|
4
|
+
# Shared by writing indexers, slug validation, and content-key resolution.
|
|
5
|
+
module ContentWritingPaths
|
|
6
|
+
CONTENT_ROOT_SEGMENT = "content"
|
|
7
|
+
|
|
8
|
+
BY_COLLECTION = {
|
|
9
|
+
"articles" => "collections/articles",
|
|
10
|
+
"posts" => "collections/posts"
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
BY_TYPE = {
|
|
14
|
+
"article" => "collections/articles",
|
|
15
|
+
"post" => "collections/posts"
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
TYPE_FOR_COLLECTION = {
|
|
19
|
+
"articles" => "article",
|
|
20
|
+
"posts" => "post"
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
COLLECTION_FOR_TYPE = TYPE_FOR_COLLECTION.invert.freeze
|
|
24
|
+
|
|
25
|
+
# Slim runtime index covers every keyed collection xcite can resolve.
|
|
26
|
+
INDEX_BY_COLLECTION = BY_COLLECTION.merge(
|
|
27
|
+
"products" => "collections/products",
|
|
28
|
+
"projects" => "collections/projects"
|
|
29
|
+
).freeze
|
|
30
|
+
|
|
31
|
+
INDEX_TYPE_FOR_COLLECTION = TYPE_FOR_COLLECTION.merge(
|
|
32
|
+
"products" => "product",
|
|
33
|
+
"projects" => "project"
|
|
34
|
+
).freeze
|
|
35
|
+
|
|
36
|
+
module_function
|
|
37
|
+
|
|
38
|
+
def content_relative(type_or_collection)
|
|
39
|
+
BY_COLLECTION[type_or_collection] || BY_TYPE.fetch(type_or_collection)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def repo_relative(type)
|
|
43
|
+
File.join(CONTENT_ROOT_SEGMENT, BY_TYPE.fetch(type))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def repo_relative_for_collection(collection, filename = nil)
|
|
47
|
+
base = File.join(CONTENT_ROOT_SEGMENT, BY_COLLECTION.fetch(collection))
|
|
48
|
+
filename ? File.join(base, filename) : base
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def repo_relative_for_index_collection(collection, filename = nil)
|
|
52
|
+
base = File.join(CONTENT_ROOT_SEGMENT, INDEX_BY_COLLECTION.fetch(collection))
|
|
53
|
+
filename ? File.join(base, filename) : base
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Force UTF-8 as the process-wide default encoding.
|
|
2
|
+
#
|
|
3
|
+
# Some build environments (e.g. the Cloudflare build image) run under a non-UTF-8
|
|
4
|
+
# locale such as `C`/`POSIX`, which makes Ruby's `Encoding.default_external` be
|
|
5
|
+
# US-ASCII. Jekyll/kramdown then raise "The source text contains invalid
|
|
6
|
+
# characters for the used encoding US-ASCII" when rendering non-ASCII content
|
|
7
|
+
# (accents, em-dashes, etc.). Netlify defaulted to a UTF-8 locale, which hid this.
|
|
8
|
+
#
|
|
9
|
+
# Plugins are loaded before content is read and rendered, so setting the defaults
|
|
10
|
+
# here makes the build locale-independent regardless of how `jekyll build` is
|
|
11
|
+
# invoked. This is a no-op where the locale is already UTF-8 (local dev, CI).
|
|
12
|
+
Encoding.default_external = Encoding::UTF_8 unless Encoding.default_external == Encoding::UTF_8
|
|
13
|
+
Encoding.default_internal = Encoding::UTF_8 unless Encoding.default_internal == Encoding::UTF_8
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "shellwords"
|
|
5
|
+
require "open3"
|
|
6
|
+
|
|
7
|
+
# Derives publication and last-modification dates for articles and posts.
|
|
8
|
+
# Shared by the content JSON export script and Jekyll build-time indexers.
|
|
9
|
+
#
|
|
10
|
+
# `date` — editorial creation date: front matter `date`, else the YYYY-MM-DD filename
|
|
11
|
+
# prefix, else the first Git add (legacy files without a dated name).
|
|
12
|
+
# `last_updated` — latest commit where the markdown body (after the closing ---) changed;
|
|
13
|
+
# front-matter-only edits do not advance it.
|
|
14
|
+
#
|
|
15
|
+
# Pitfall (see docs/content/architecture.md): history often crosses renames. git log --follow
|
|
16
|
+
# reaches pre-migration commits, but git show <sha>:<current-path> does not — the path did
|
|
17
|
+
# not exist yet. A failed show must never be treated as an empty body; resolve the path per
|
|
18
|
+
# commit via git_commit_entries (--name-only) before comparing bodies.
|
|
19
|
+
module GitChronology
|
|
20
|
+
WRITING_TYPES = %w[article post].freeze
|
|
21
|
+
FILENAME_DATE_RE = /\A(\d{4}-\d{2}-\d{2})-/.freeze
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
def reset_cache!
|
|
26
|
+
@chronology_memo = {}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def git_repo?
|
|
30
|
+
system("git rev-parse --is-inside-work-tree > /dev/null 2>&1")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def git_date(repo_relative_path, mode)
|
|
34
|
+
return nil unless git_repo?
|
|
35
|
+
|
|
36
|
+
path = repo_relative_path.to_s
|
|
37
|
+
case mode
|
|
38
|
+
when :creation
|
|
39
|
+
raw_out, _status = Open3.capture2("git", "log", "--follow", "--diff-filter=A", "--format=%ad", "--date=short", "--", path)
|
|
40
|
+
raw = raw_out.strip.split("\n").last
|
|
41
|
+
when :updated
|
|
42
|
+
# AM skips pure rename/copy commits (e.g. content migration) while still
|
|
43
|
+
# returning the add date for files that have only been created once.
|
|
44
|
+
raw_out, _status = Open3.capture2("git", "log", "--follow", "--diff-filter=AM", "-1", "--format=%ad", "--date=short", "--", path)
|
|
45
|
+
raw = raw_out.strip
|
|
46
|
+
else
|
|
47
|
+
return nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
raw unless raw.nil? || raw.empty?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def split_front_matter(text)
|
|
54
|
+
text = text.to_s.sub(/\A\xEF\xBB\xBF/, "")
|
|
55
|
+
return ["", text] unless text.start_with?("---\n")
|
|
56
|
+
|
|
57
|
+
parts = text.split(/^---\s*$\n?/, 3)
|
|
58
|
+
return ["", text] unless parts.length == 3
|
|
59
|
+
|
|
60
|
+
[parts[1], parts[2]]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def markdown_body(text)
|
|
64
|
+
_front_matter, body = split_front_matter(text)
|
|
65
|
+
body
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Chronology compares semantic body content, not formatting noise from CMS saves
|
|
69
|
+
# (line endings, trailing spaces, or blank lines added/removed without text edits).
|
|
70
|
+
def normalize_body_for_chronology(body)
|
|
71
|
+
body.to_s
|
|
72
|
+
.gsub(/\r\n?/, "\n")
|
|
73
|
+
.each_line
|
|
74
|
+
.map(&:strip)
|
|
75
|
+
.reject(&:empty?)
|
|
76
|
+
.join("\n")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def bodies_changed_for_chronology?(body_a, body_b)
|
|
80
|
+
normalize_body_for_chronology(body_a) != normalize_body_for_chronology(body_b)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
SHA_RE = /\A[0-9a-f]{40}\z/i
|
|
84
|
+
|
|
85
|
+
# Newest-first [commit_sha, repo_relative_path_at_that_commit] pairs.
|
|
86
|
+
#
|
|
87
|
+
# --follow alone is insufficient for body chronology: each revision must be read at the
|
|
88
|
+
# path that existed in that commit (_posts/… before the 2026-06-19 content/ migration,
|
|
89
|
+
# content/collections/posts/… after). Without --name-only, git show sha:current_path
|
|
90
|
+
# fails on older commits and last_updated incorrectly snaps to the rename date.
|
|
91
|
+
def git_commit_entries(repo_relative_path)
|
|
92
|
+
return [] unless git_repo?
|
|
93
|
+
|
|
94
|
+
path = repo_relative_path.to_s
|
|
95
|
+
out, _status = Open3.capture2("git", "log", "--follow", "--name-only", "--format=%H", "--", path)
|
|
96
|
+
lines = out.each_line.map(&:strip).reject(&:empty?)
|
|
97
|
+
return [] if lines.empty?
|
|
98
|
+
|
|
99
|
+
entries = []
|
|
100
|
+
index = 0
|
|
101
|
+
while index < lines.length
|
|
102
|
+
sha = lines[index]
|
|
103
|
+
break unless sha.match?(SHA_RE)
|
|
104
|
+
|
|
105
|
+
index += 1
|
|
106
|
+
paths = []
|
|
107
|
+
while index < lines.length && !lines[index].match?(SHA_RE)
|
|
108
|
+
paths << lines[index]
|
|
109
|
+
index += 1
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
path_at_commit = paths.empty? ? repo_relative_path.to_s : pick_path_at_commit(paths, repo_relative_path)
|
|
113
|
+
entries << [sha, path_at_commit]
|
|
114
|
+
end
|
|
115
|
+
entries
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def pick_path_at_commit(paths, repo_relative_path)
|
|
119
|
+
basename = File.basename(repo_relative_path.to_s)
|
|
120
|
+
paths.find { |candidate| candidate.end_with?(basename) } || paths.last
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def commit_date(sha)
|
|
124
|
+
out, _status = Open3.capture2("git", "log", "-1", "--format=%ad", "--date=short", sha)
|
|
125
|
+
out.strip
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def file_content_at_commit(path_at_commit, sha)
|
|
129
|
+
# path_at_commit comes from git_commit_entries, not the caller's present-day path.
|
|
130
|
+
path = path_at_commit.to_s
|
|
131
|
+
content, status = Open3.capture2("git", "show", "#{sha}:#{path}", err: File::NULL)
|
|
132
|
+
return nil unless status.success?
|
|
133
|
+
return nil if content.nil? || content.empty?
|
|
134
|
+
|
|
135
|
+
content
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def git_body_updated_date(repo_relative_path)
|
|
139
|
+
entries = git_commit_entries(repo_relative_path)
|
|
140
|
+
return nil if entries.empty?
|
|
141
|
+
|
|
142
|
+
# Walk newest → oldest; stop at the first pair where bodies differ.
|
|
143
|
+
# Do not substitute "" when file_content_at_commit returns nil — that was the
|
|
144
|
+
# migration bug (missing historical path read as an empty body edit).
|
|
145
|
+
entries.each_with_index do |(sha, path_at_commit), idx|
|
|
146
|
+
body = markdown_body(file_content_at_commit(path_at_commit, sha) || "")
|
|
147
|
+
if idx == entries.length - 1
|
|
148
|
+
return nil
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
older_sha, older_path = entries[idx + 1]
|
|
152
|
+
older_body = markdown_body(file_content_at_commit(older_path, older_sha) || "")
|
|
153
|
+
return commit_date(sha) if bodies_changed_for_chronology?(body, older_body)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
nil
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def filename_date(repo_relative_path)
|
|
160
|
+
stem = File.basename(repo_relative_path.to_s, File.extname(repo_relative_path.to_s))
|
|
161
|
+
match = stem.match(FILENAME_DATE_RE)
|
|
162
|
+
normalize_date_value(match[1]) if match
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def editorial_date(front_matter, repo_relative_path)
|
|
166
|
+
normalize_date_value(front_matter["date"]) ||
|
|
167
|
+
filename_date(repo_relative_path) ||
|
|
168
|
+
git_date(repo_relative_path, :creation)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def chronology_for(repo_relative_path, type: nil, front_matter: nil)
|
|
172
|
+
return {} unless type.nil? || WRITING_TYPES.include?(type)
|
|
173
|
+
|
|
174
|
+
@chronology_memo ||= {}
|
|
175
|
+
memo_key = [repo_relative_path.to_s, type, normalize_date_value(front_matter&.dig("date"))]
|
|
176
|
+
return @chronology_memo[memo_key] if @chronology_memo.key?(memo_key)
|
|
177
|
+
|
|
178
|
+
fm = front_matter || {}
|
|
179
|
+
creation = editorial_date(fm, repo_relative_path)
|
|
180
|
+
updated = git_body_updated_date(repo_relative_path)
|
|
181
|
+
last_updated = updated || creation
|
|
182
|
+
|
|
183
|
+
result = {}
|
|
184
|
+
result["date"] = creation if creation
|
|
185
|
+
result["last_updated"] = last_updated if last_updated
|
|
186
|
+
@chronology_memo[memo_key] = result
|
|
187
|
+
result
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def normalize_date_value(value)
|
|
191
|
+
case value
|
|
192
|
+
when Date then value.iso8601
|
|
193
|
+
when Time then value.to_date.iso8601
|
|
194
|
+
when String
|
|
195
|
+
stripped = value.strip
|
|
196
|
+
stripped.empty? ? nil : stripped
|
|
197
|
+
else
|
|
198
|
+
raw = value&.to_s&.strip
|
|
199
|
+
raw.nil? || raw.empty? ? nil : raw
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def apply!(front_matter, repo_relative_path, type)
|
|
204
|
+
return front_matter unless WRITING_TYPES.include?(type)
|
|
205
|
+
|
|
206
|
+
chronology = chronology_for(repo_relative_path, type: type, front_matter: front_matter)
|
|
207
|
+
front_matter["date"] = chronology["date"] if chronology["date"]
|
|
208
|
+
front_matter["last_updated"] = chronology["last_updated"] if chronology["last_updated"]
|
|
209
|
+
front_matter
|
|
210
|
+
end
|
|
211
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
require 'date'
|
|
2
|
+
require 'time'
|
|
3
|
+
|
|
4
|
+
# This plugin automatically derives 'date' and 'last_updated' from Git history
|
|
5
|
+
# for articles and posts, ensuring Git is the single source of truth for chronology.
|
|
6
|
+
module Jekyll
|
|
7
|
+
class GitMetadataGenerator < Generator
|
|
8
|
+
safe true
|
|
9
|
+
priority :high
|
|
10
|
+
|
|
11
|
+
def generate(site)
|
|
12
|
+
return unless GitChronology.git_repo?
|
|
13
|
+
|
|
14
|
+
article_docs = site.collections["articles"]&.docs || []
|
|
15
|
+
post_docs = site.collections["posts"]&.docs || []
|
|
16
|
+
return if article_docs.empty? && post_docs.empty?
|
|
17
|
+
|
|
18
|
+
(article_docs + post_docs).each do |doc|
|
|
19
|
+
next unless doc.path.match?(/\.(md|markdown)$/)
|
|
20
|
+
|
|
21
|
+
doc.data['language'] = doc.collection.label == 'articles' ? 'enus' : 'ptbr'
|
|
22
|
+
GitChronology.apply!(doc.data, doc.path, doc.collection.label == 'articles' ? 'article' : 'post')
|
|
23
|
+
|
|
24
|
+
if doc.data['date']
|
|
25
|
+
begin
|
|
26
|
+
doc.data['date'] = Time.parse(doc.data['date'].to_s)
|
|
27
|
+
rescue ArgumentError
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
if doc.data['last_updated']
|
|
32
|
+
begin
|
|
33
|
+
doc.data['last_updated'] = Time.parse(doc.data['last_updated'].to_s)
|
|
34
|
+
rescue ArgumentError
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
article_docs.sort_by! { |d| d.data['last_updated'] || d.data['date'] || Time.now }.reverse!
|
|
40
|
+
post_docs.sort_by! { |d| d.data['last_updated'] || d.data['date'] || Time.now }.reverse!
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Renders the private CV job body fields to HTML for the print layouts,
|
|
4
|
+
# storing the result in doc.data["extended"/"single_page"].
|
|
5
|
+
# body_public is not rendered here: the public CV page reads it straight
|
|
6
|
+
# from the exported JSON through the client-side runtime.
|
|
7
|
+
module Jekyll
|
|
8
|
+
class JobSectionsGenerator < Generator
|
|
9
|
+
safe true
|
|
10
|
+
priority :high
|
|
11
|
+
|
|
12
|
+
def generate(site)
|
|
13
|
+
return if site.config["shell_content_agnostic"]
|
|
14
|
+
|
|
15
|
+
converter = site.find_converter_instance(Jekyll::Converters::Markdown)
|
|
16
|
+
|
|
17
|
+
(site.collections["jobs"]&.docs || []).each do |doc|
|
|
18
|
+
%w[extended single_page].each do |key|
|
|
19
|
+
markdown = doc.data["body_#{key}"].to_s.strip
|
|
20
|
+
doc.data[key] = converter.convert(markdown) unless markdown.empty?
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
require_relative "resource_validator"
|
|
6
|
+
require_relative "tag_validator"
|
|
7
|
+
require_relative "../writing_slug_limits"
|
|
8
|
+
require_relative "../content_keys"
|
|
9
|
+
|
|
10
|
+
# Single validation entry point for editorial content under content/.
|
|
11
|
+
module ContentValidators
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def validate_all!(repo_root = Pathname.pwd)
|
|
15
|
+
root = Pathname.new(repo_root)
|
|
16
|
+
errors = []
|
|
17
|
+
errors.concat(ResourceValidator.collect_errors(root))
|
|
18
|
+
errors.concat(TagValidator.collect_errors(root))
|
|
19
|
+
errors.concat(WritingSlugLimits.collect_errors(root))
|
|
20
|
+
|
|
21
|
+
unless errors.empty?
|
|
22
|
+
raise ArgumentError, "Content validation failed:\n - #{errors.join("\n - ")}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
ContentKeys.validate!(root)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
# Shared resource collection validation (content/ source, not Jekyll collections).
|
|
7
|
+
module ResourceValidator
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def split_front_matter(text)
|
|
11
|
+
text = text.to_s.sub(/\A\xEF\xBB\xBF/, "")
|
|
12
|
+
return [{}, text] unless text.start_with?("---\n")
|
|
13
|
+
|
|
14
|
+
parts = text.split(/^---\s*$\n?/, 3)
|
|
15
|
+
return [{}, text] unless parts.length == 3
|
|
16
|
+
|
|
17
|
+
data = YAML.safe_load(parts[1], permitted_classes: [Date, Time], aliases: true) || {}
|
|
18
|
+
[data, parts[2]]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def collect_errors(repo_root)
|
|
22
|
+
root = Pathname.new(repo_root)
|
|
23
|
+
resources_dir = root.join("content/collections/resources")
|
|
24
|
+
return [] unless resources_dir.directory?
|
|
25
|
+
|
|
26
|
+
errors = []
|
|
27
|
+
resources_dir.glob("*.{md,markdown}").sort.each do |source|
|
|
28
|
+
front_matter, = split_front_matter(source.read)
|
|
29
|
+
name = front_matter["name"] || source.basename(source.extname).to_s
|
|
30
|
+
type = front_matter["type"].to_s.downcase
|
|
31
|
+
link = front_matter["link"].to_s
|
|
32
|
+
|
|
33
|
+
if link.empty?
|
|
34
|
+
errors << "#{name}: missing required 'link' field"
|
|
35
|
+
next
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
case type
|
|
39
|
+
when "youtube"
|
|
40
|
+
unless link.match?(%r{\Ahttps?://(www\.)?(youtube\.com|youtu\.be)/}i)
|
|
41
|
+
errors << "#{name}: type 'youtube' but link does not point to youtube.com or youtu.be (got: #{link})"
|
|
42
|
+
end
|
|
43
|
+
when "spotify"
|
|
44
|
+
unless link.match?(%r{\Ahttps?://(open\.)?spotify\.com/}i)
|
|
45
|
+
errors << "#{name}: type 'spotify' but link does not point to spotify.com (got: #{link})"
|
|
46
|
+
end
|
|
47
|
+
when "pdf"
|
|
48
|
+
unless link.end_with?(".pdf")
|
|
49
|
+
errors << "#{name}: type 'pdf' but link does not end with .pdf (got: #{link})"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
errors
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "yaml"
|
|
5
|
+
require "date"
|
|
6
|
+
|
|
7
|
+
# Tags replaced category as the single classification axis for articles and
|
|
8
|
+
# rascunhos. Vocabulary is closed per collection and loaded from
|
|
9
|
+
# `_config.yml` → `pandorga.taxonomies` (PLT Phase 1).
|
|
10
|
+
module TagValidator
|
|
11
|
+
DEFAULT_MAX_TAGS = 3
|
|
12
|
+
|
|
13
|
+
COLLECTIONS_WITHOUT_REQUIRED_TAGS = %w[posts].freeze
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def load_vocabularies(repo_root = Pathname.pwd)
|
|
18
|
+
root = Pathname.new(repo_root)
|
|
19
|
+
config_path = root.join("_config.yml")
|
|
20
|
+
return {} unless config_path.file?
|
|
21
|
+
|
|
22
|
+
config = YAML.safe_load(config_path.read, permitted_classes: [Date, Time], aliases: true) || {}
|
|
23
|
+
vocab = {}
|
|
24
|
+
|
|
25
|
+
taxonomies = config.dig("pandorga", "taxonomies")
|
|
26
|
+
if taxonomies.is_a?(Hash)
|
|
27
|
+
taxonomies.each do |collection, spec|
|
|
28
|
+
next unless spec.is_a?(Hash)
|
|
29
|
+
|
|
30
|
+
tags = spec["tags"]
|
|
31
|
+
next unless tags.is_a?(Array)
|
|
32
|
+
|
|
33
|
+
vocab[collection.to_s] = tags.map(&:to_s).freeze
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Page-local taxonomy (pandorga.pages[].taxonomy) fills gaps.
|
|
38
|
+
pages = config.dig("pandorga", "pages")
|
|
39
|
+
if pages.is_a?(Array)
|
|
40
|
+
pages.each do |page|
|
|
41
|
+
next unless page.is_a?(Hash)
|
|
42
|
+
|
|
43
|
+
collection = page["collection"].to_s
|
|
44
|
+
next if collection.empty? || vocab.key?(collection)
|
|
45
|
+
|
|
46
|
+
spec = page["taxonomy"]
|
|
47
|
+
next unless spec.is_a?(Hash)
|
|
48
|
+
|
|
49
|
+
tags = spec["tags"]
|
|
50
|
+
next unless tags.is_a?(Array)
|
|
51
|
+
|
|
52
|
+
vocab[collection] = tags.map(&:to_s).freeze
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
vocab
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def max_tags_for(collection, repo_root = Pathname.pwd)
|
|
60
|
+
root = Pathname.new(repo_root)
|
|
61
|
+
config_path = root.join("_config.yml")
|
|
62
|
+
return DEFAULT_MAX_TAGS unless config_path.file?
|
|
63
|
+
|
|
64
|
+
config = YAML.safe_load(config_path.read, permitted_classes: [Date, Time], aliases: true) || {}
|
|
65
|
+
max = config.dig("pandorga", "taxonomies", collection.to_s, "max")
|
|
66
|
+
if max.nil?
|
|
67
|
+
pages = config.dig("pandorga", "pages")
|
|
68
|
+
if pages.is_a?(Array)
|
|
69
|
+
page = pages.find { |p| p.is_a?(Hash) && p["collection"].to_s == collection.to_s }
|
|
70
|
+
max = page.dig("taxonomy", "max") if page.is_a?(Hash)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
max.nil? ? DEFAULT_MAX_TAGS : Integer(max)
|
|
74
|
+
rescue ArgumentError, TypeError
|
|
75
|
+
DEFAULT_MAX_TAGS
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def collect_errors(repo_root = Pathname.pwd)
|
|
79
|
+
root = Pathname.new(repo_root)
|
|
80
|
+
errors = []
|
|
81
|
+
vocabulary = load_vocabularies(root)
|
|
82
|
+
|
|
83
|
+
if vocabulary.empty?
|
|
84
|
+
errors << "_config.yml: pandorga.taxonomies is missing or empty"
|
|
85
|
+
return errors
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
vocabulary.each do |collection, allowed|
|
|
89
|
+
dir = root.join("content", "collections", collection)
|
|
90
|
+
next unless dir.directory?
|
|
91
|
+
|
|
92
|
+
tags_required = !COLLECTIONS_WITHOUT_REQUIRED_TAGS.include?(collection)
|
|
93
|
+
max_tags = max_tags_for(collection, root)
|
|
94
|
+
|
|
95
|
+
Dir.glob(dir.join("*.md")).sort.each do |path|
|
|
96
|
+
relative = Pathname.new(path).relative_path_from(root)
|
|
97
|
+
front_matter = read_front_matter(path)
|
|
98
|
+
next if front_matter.nil?
|
|
99
|
+
|
|
100
|
+
if front_matter.key?("category")
|
|
101
|
+
errors << "#{relative}: uses `category`; tags replaced it as the " \
|
|
102
|
+
"single classification axis"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
tags = front_matter["tags"]
|
|
106
|
+
if tags.nil?
|
|
107
|
+
errors << "#{relative}: missing `tags`" if tags_required
|
|
108
|
+
next
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
unless tags.is_a?(Array)
|
|
112
|
+
errors << "#{relative}: `tags` must be a list, got #{tags.class}"
|
|
113
|
+
next
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
if tags.empty?
|
|
117
|
+
errors << "#{relative}: `tags` is empty; every entry needs at least one" if tags_required
|
|
118
|
+
next
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
if tags.size > max_tags
|
|
122
|
+
errors << "#{relative}: #{tags.size} tags exceeds the #{max_tags} " \
|
|
123
|
+
"allowed — pick the ones that would actually be filtered on"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
if tags.uniq.size != tags.size
|
|
127
|
+
errors << "#{relative}: duplicate tags #{(tags - tags.uniq).uniq.inspect}"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
unknown = tags.reject { |tag| allowed.include?(tag.to_s) }
|
|
131
|
+
next if unknown.empty?
|
|
132
|
+
|
|
133
|
+
errors << "#{relative}: unknown #{collection} tag(s) #{unknown.inspect}. " \
|
|
134
|
+
"Allowed: #{allowed.join(', ')}"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
errors
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def read_front_matter(path)
|
|
142
|
+
source = File.read(path)
|
|
143
|
+
return nil unless source.start_with?("---")
|
|
144
|
+
|
|
145
|
+
raw = source.split(/^---\s*$/, 3)[1]
|
|
146
|
+
return nil if raw.nil?
|
|
147
|
+
|
|
148
|
+
parsed = YAML.safe_load(raw, permitted_classes: [Date, Time], aliases: true)
|
|
149
|
+
parsed.is_a?(Hash) ? parsed : nil
|
|
150
|
+
rescue Psych::SyntaxError
|
|
151
|
+
nil
|
|
152
|
+
end
|
|
153
|
+
end
|