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,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "json"
|
|
6
|
+
require "rbconfig"
|
|
7
|
+
require "jekyll"
|
|
8
|
+
|
|
9
|
+
# When local_content_serve is enabled (_config_dev.yml), copies the content
|
|
10
|
+
# pipeline export and editorial media into the Jekyll output so the browser
|
|
11
|
+
# runtime reads the same JSON layout as Cloudflare R2.
|
|
12
|
+
module Jekyll
|
|
13
|
+
module ContentLocalServe
|
|
14
|
+
EXPORT_MUTEX = Mutex.new
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
attr_accessor :serve_options, :site_config
|
|
18
|
+
|
|
19
|
+
def enabled?(site)
|
|
20
|
+
site.config["local_content_serve"] == true
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def export_root(site)
|
|
24
|
+
File.join(site.source, site.config["local_content_export_dir"] || "_content_json")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def content_root(site)
|
|
28
|
+
File.join(site.source, site.config["content_root"] || "content")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Cold start only. The export stamps `generated_at` into manifest.json, and
|
|
32
|
+
# it writes into a directory Jekyll watches, so exporting on every build
|
|
33
|
+
# makes `jekyll serve --watch` regenerate itself forever. dev-serve.sh owns
|
|
34
|
+
# the re-export when content changes; this covers the case where somebody
|
|
35
|
+
# runs Jekyll against the dev config with no export on disk at all.
|
|
36
|
+
def export_script(site)
|
|
37
|
+
site_script = File.join(site.source, "scripts", "content", "export-content-json.rb")
|
|
38
|
+
return site_script if File.file?(site_script)
|
|
39
|
+
|
|
40
|
+
begin
|
|
41
|
+
require "pandorga"
|
|
42
|
+
gem_script = Pandorga.gem_path("scripts/content/export-content-json.rb")
|
|
43
|
+
return gem_script if File.file?(gem_script)
|
|
44
|
+
rescue LoadError
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def export_content!(site)
|
|
50
|
+
return if File.directory?(export_root(site))
|
|
51
|
+
|
|
52
|
+
script = export_script(site)
|
|
53
|
+
return unless script
|
|
54
|
+
|
|
55
|
+
content_dir = site.config["content_root"] || "content"
|
|
56
|
+
output_dir = site.config["local_content_export_dir"] || "_content_json"
|
|
57
|
+
env = {
|
|
58
|
+
"CONTENT_API_BASE_URL" => site.config["content_api_base_url"].to_s.strip,
|
|
59
|
+
"CONTENT_EXPORT_INCLUDE_UNPUBLISHED" => site.config["content_include_unpublished"] == true ? "1" : "0",
|
|
60
|
+
"PANDORGA_SITE_ROOT" => site.source.to_s,
|
|
61
|
+
"CONTENT_EXPORT_SKIP_VALIDATE" => "1"
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
ruby_bin = RbConfig.ruby
|
|
65
|
+
success = system(env, ruby_bin, script, content_dir, output_dir, chdir: site.source)
|
|
66
|
+
return if success
|
|
67
|
+
|
|
68
|
+
Jekyll.logger.error "ContentLocalServe:", "export-content-json.rb failed"
|
|
69
|
+
raise "Content export failed; run bundle exec pandorga export content _content_json"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def copy_tree(source, destination)
|
|
73
|
+
return unless File.directory?(source)
|
|
74
|
+
|
|
75
|
+
Pathname.glob(File.join(source, "**", "*"), File::FNM_DOTMATCH).each do |path|
|
|
76
|
+
next if path.directory?
|
|
77
|
+
|
|
78
|
+
relative = path.relative_path_from(Pathname.new(source)).to_s
|
|
79
|
+
target = File.join(destination, relative)
|
|
80
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
81
|
+
FileUtils.cp(path.to_s, target)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def publish!(site)
|
|
86
|
+
export_root = export_root(site)
|
|
87
|
+
export_content!(site)
|
|
88
|
+
|
|
89
|
+
copy_tree(export_root, site.dest)
|
|
90
|
+
copy_tree(File.join(content_root(site), "media"), File.join(site.dest, "media"))
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def store_serve_options(options)
|
|
94
|
+
@serve_options = Jekyll.configuration(options)
|
|
95
|
+
rescue StandardError
|
|
96
|
+
@serve_options = options
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def reexport!(options = nil)
|
|
100
|
+
opts = options || serve_options || site_config || {}
|
|
101
|
+
source = opts["source"] || Dir.pwd
|
|
102
|
+
destination = opts["destination"] || File.join(source, "_site")
|
|
103
|
+
content_dir = opts["content_root"] || "content"
|
|
104
|
+
output_dir = opts["local_content_export_dir"] || "_content_json"
|
|
105
|
+
script = File.join(source, "scripts", "content", "export-content-json.rb")
|
|
106
|
+
|
|
107
|
+
return [false, "Missing export script at #{script}"] unless File.file?(script)
|
|
108
|
+
|
|
109
|
+
env = {
|
|
110
|
+
"CONTENT_API_BASE_URL" => opts.fetch("content_api_base_url", "").to_s.strip,
|
|
111
|
+
"CONTENT_EXPORT_INCLUDE_UNPUBLISHED" => (opts["content_include_unpublished"] != false) ? "1" : "0"
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
ruby_bin = RbConfig.ruby
|
|
115
|
+
Jekyll.logger.info "ContentLocalServe:", "Re-exporting content JSON..."
|
|
116
|
+
success = system(env, ruby_bin, script, content_dir, output_dir, chdir: source)
|
|
117
|
+
return [false, "export-content-json.rb execution failed"] unless success
|
|
118
|
+
|
|
119
|
+
export_path = File.join(source, output_dir)
|
|
120
|
+
copy_tree(export_path, destination)
|
|
121
|
+
copy_tree(File.join(source, content_dir, "media"), File.join(destination, "media"))
|
|
122
|
+
|
|
123
|
+
shell_trigger = File.join(source, "pages", "articles", "index.html")
|
|
124
|
+
FileUtils.touch(shell_trigger) if File.file?(shell_trigger)
|
|
125
|
+
|
|
126
|
+
Jekyll.logger.info "ContentLocalServe:", "Re-export complete and mirrored to #{destination}"
|
|
127
|
+
[true, "Content JSON re-exported successfully"]
|
|
128
|
+
rescue StandardError => e
|
|
129
|
+
Jekyll.logger.error "ContentLocalServe:", "Re-export error: #{e.message}"
|
|
130
|
+
[false, "Re-export error: #{e.message}"]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def handle_reexport_request(_req, res)
|
|
134
|
+
res["Content-Type"] = "application/json; charset=utf-8"
|
|
135
|
+
res["Cache-Control"] = "no-store, no-cache, must-revalidate"
|
|
136
|
+
|
|
137
|
+
opts = serve_options || site_config || {}
|
|
138
|
+
unless opts["content_include_unpublished"] || opts["unpublished"] || opts["local_content_serve"]
|
|
139
|
+
res.status = 403
|
|
140
|
+
res.body = JSON.generate({ "ok" => false, "error" => "Dev re-export is disabled in production mode" })
|
|
141
|
+
return
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
EXPORT_MUTEX.synchronize do
|
|
145
|
+
success, message = reexport!(opts)
|
|
146
|
+
if success
|
|
147
|
+
res.status = 200
|
|
148
|
+
res.body = JSON.generate({ "ok" => true, "message" => message })
|
|
149
|
+
else
|
|
150
|
+
res.status = 500
|
|
151
|
+
res.body = JSON.generate({ "ok" => false, "error" => message })
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
Jekyll::Hooks.register :site, :after_init do |site|
|
|
158
|
+
ContentLocalServe.site_config = site.config
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
Jekyll::Hooks.register :site, :post_write do |site|
|
|
162
|
+
next unless ContentLocalServe.enabled?(site)
|
|
163
|
+
|
|
164
|
+
ContentLocalServe.publish!(site)
|
|
165
|
+
Jekyll.logger.info "ContentLocalServe:", "Published local content JSON and media to #{site.dest}"
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "ostruct"
|
|
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
|
+
begin
|
|
15
|
+
require "pandorga/registry"
|
|
16
|
+
rescue LoadError
|
|
17
|
+
# Exporter may load this file before the gem lib path is on $LOAD_PATH.
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Minimal site-like object built from /content for export-time derived data
|
|
21
|
+
# (timeline, writing_index) without running a Jekyll build.
|
|
22
|
+
class ContentSiteAdapter
|
|
23
|
+
Doc = Struct.new(:data, :url, :path, keyword_init: true)
|
|
24
|
+
|
|
25
|
+
COLLECTION_DIRS = {
|
|
26
|
+
"products" => "collections/products",
|
|
27
|
+
"projects" => "collections/projects",
|
|
28
|
+
"jobs" => "collections/jobs"
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
attr_reader :collections, :data
|
|
32
|
+
|
|
33
|
+
def initialize(content_root, include_writing_index: true, config: nil)
|
|
34
|
+
@content_root = Pathname.new(content_root)
|
|
35
|
+
@config = config.is_a?(Hash) ? config : load_site_config
|
|
36
|
+
@collections = build_collections
|
|
37
|
+
@data = {
|
|
38
|
+
"education" => load_yaml_array("collections/data/education.yml"),
|
|
39
|
+
"writing_index" => include_writing_index ? build_writing_index : []
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def load_site_config
|
|
46
|
+
root = Pathname.new(ENV.fetch("PANDORGA_SITE_ROOT", Dir.pwd)).expand_path
|
|
47
|
+
path = root.join("_config.yml")
|
|
48
|
+
return {} unless path.file?
|
|
49
|
+
|
|
50
|
+
YAML.safe_load(path.read, permitted_classes: [Date, Time], aliases: true) || {}
|
|
51
|
+
rescue StandardError
|
|
52
|
+
{}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def collection_dirs
|
|
56
|
+
return COLLECTION_DIRS unless defined?(PandorgaRegistry)
|
|
57
|
+
|
|
58
|
+
pages = PandorgaRegistry.pages(@config)
|
|
59
|
+
return COLLECTION_DIRS if pages.empty?
|
|
60
|
+
|
|
61
|
+
dirs = PandorgaRegistry.adapter_collection_dirs(pages)
|
|
62
|
+
dirs.empty? ? COLLECTION_DIRS : dirs
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def build_collections
|
|
66
|
+
collection_dirs.each_with_object({}) do |(name, relative), hash|
|
|
67
|
+
hash[name] = OpenStruct.new(docs: load_collection_docs(relative, name))
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def load_collection_docs(relative_dir, collection_name)
|
|
72
|
+
dir = @content_root.join(relative_dir)
|
|
73
|
+
return [] unless dir.directory?
|
|
74
|
+
|
|
75
|
+
type = case collection_name
|
|
76
|
+
when "products" then "product"
|
|
77
|
+
when "projects" then "project"
|
|
78
|
+
when "jobs" then "job"
|
|
79
|
+
else collection_name
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
Dir.glob(dir.join("*.{md,markdown}")).sort.filter_map do |absolute_path|
|
|
83
|
+
text = File.read(absolute_path).sub(/\A\xEF\xBB\xBF/, "")
|
|
84
|
+
front_matter, = split_front_matter(text)
|
|
85
|
+
next if front_matter["published"] == false
|
|
86
|
+
|
|
87
|
+
if ContentKeys::KEYED_TYPES.include?(type)
|
|
88
|
+
key = ContentKeys.normalize_key(front_matter["key"])
|
|
89
|
+
path_segment = ContentKeys.path_segment(type, key, date: front_matter["date"])
|
|
90
|
+
url = ContentKeys.detail_path(type, key, date: front_matter["date"])
|
|
91
|
+
else
|
|
92
|
+
key = ContentKeys.filename_stem(absolute_path)
|
|
93
|
+
path_segment = key
|
|
94
|
+
url = "/#{collection_name}/#{path_segment}/"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
Doc.new(
|
|
98
|
+
data: stringify_keys(front_matter.merge("key" => key, "slug" => path_segment)),
|
|
99
|
+
url: url,
|
|
100
|
+
path: absolute_path
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def build_writing_index
|
|
106
|
+
entries = []
|
|
107
|
+
index_collections.each do |collection, relative_path|
|
|
108
|
+
directory = @content_root.join(relative_path)
|
|
109
|
+
next unless directory.directory?
|
|
110
|
+
|
|
111
|
+
Dir.glob(directory.join("*.{md,markdown}")).sort.each do |absolute_path|
|
|
112
|
+
entry = build_writing_entry(absolute_path, collection)
|
|
113
|
+
entries << entry if entry
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
entries
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def index_collections
|
|
120
|
+
if defined?(PandorgaRegistry)
|
|
121
|
+
pages = PandorgaRegistry.pages(@config)
|
|
122
|
+
unless pages.empty?
|
|
123
|
+
map = PandorgaRegistry.writing_collections(pages)
|
|
124
|
+
return map.keys.each_with_object({}) do |name, hash|
|
|
125
|
+
hash[name] = "collections/#{name}"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
ContentWritingPaths::INDEX_BY_COLLECTION
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def build_writing_entry(absolute_path, collection)
|
|
133
|
+
relative_source = Pathname.new(absolute_path).relative_path_from(@content_root).to_s
|
|
134
|
+
repo_relative_path = Pathname.new(ContentWritingPaths::CONTENT_ROOT_SEGMENT)
|
|
135
|
+
.join(relative_source).to_s
|
|
136
|
+
WritingEntry.build(absolute_path, collection, repo_relative_path)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def load_yaml_array(relative_path)
|
|
140
|
+
path = @content_root.join(relative_path)
|
|
141
|
+
return [] unless path.file?
|
|
142
|
+
|
|
143
|
+
YAML.safe_load_file(path, permitted_classes: [Date, Time], aliases: true) || []
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def split_front_matter(text)
|
|
147
|
+
return [{}, text] unless text.start_with?("---\n")
|
|
148
|
+
|
|
149
|
+
parts = text.split(/^---\s*$\n?/, 3)
|
|
150
|
+
return [{}, text] unless parts.length == 3
|
|
151
|
+
|
|
152
|
+
data = YAML.safe_load(parts[1], permitted_classes: [Date, Time], aliases: true) || {}
|
|
153
|
+
[data, parts[2]]
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def stringify_keys(hash)
|
|
157
|
+
hash.transform_keys(&:to_s)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
require_relative "lib/content_validators"
|
|
6
|
+
|
|
7
|
+
# Editorial validation for content-aware builds only. Public Pages shells set
|
|
8
|
+
# shell_content_agnostic and skip this — gates live in validate.sh / export.
|
|
9
|
+
module Jekyll
|
|
10
|
+
class ContentValidatorsHook < Generator
|
|
11
|
+
safe true
|
|
12
|
+
priority :highest
|
|
13
|
+
|
|
14
|
+
def generate(site)
|
|
15
|
+
return if site.config["shell_content_agnostic"]
|
|
16
|
+
|
|
17
|
+
ContentValidators.validate_all!(Pathname.new(site.source))
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -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
|