jekyll-obsidian-site 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +133 -0
  4. data/website/_config.yml +21 -0
  5. data/website/_includes/website/archive-timeline.html +8 -0
  6. data/website/_includes/website/blog-ledger.html +19 -0
  7. data/website/_includes/website/blog-navigation.html +7 -0
  8. data/website/_includes/website/comments.html +22 -0
  9. data/website/_includes/website/contact-icon.html +14 -0
  10. data/website/_includes/website/context-tags.html +7 -0
  11. data/website/_includes/website/context.html +3 -0
  12. data/website/_includes/website/dialogs.html +71 -0
  13. data/website/_includes/website/docs-navigation.html +7 -0
  14. data/website/_includes/website/docs-tree.html +8 -0
  15. data/website/_includes/website/footer.html +14 -0
  16. data/website/_includes/website/github-icon.html +3 -0
  17. data/website/_includes/website/head.html +41 -0
  18. data/website/_includes/website/header.html +35 -0
  19. data/website/_includes/website/language-switcher.html +16 -0
  20. data/website/_includes/website/local-graph.html +25 -0
  21. data/website/_includes/website/note-header.html +35 -0
  22. data/website/_includes/website/outline.html +9 -0
  23. data/website/_includes/website/page-actions.html +34 -0
  24. data/website/_includes/website/portfolio-grid.html +36 -0
  25. data/website/_includes/website/portfolio-topics.html +4 -0
  26. data/website/_includes/website/post-cards.html +16 -0
  27. data/website/_includes/website/related-articles.html +7 -0
  28. data/website/_includes/website/relations.html +16 -0
  29. data/website/_includes/website/source-actions.html +28 -0
  30. data/website/_includes/website/system-page.html +31 -0
  31. data/website/_includes/website/tab-collection.html +28 -0
  32. data/website/_includes/website/tag-filter.html +12 -0
  33. data/website/_layouts/website-docs.html +20 -0
  34. data/website/_layouts/website-minimal.html +93 -0
  35. data/website/_layouts/website-redirect.html +17 -0
  36. data/website/assets/chunks/abnfDiagram-N423BO3Z-TSORAEI2.js +1 -0
  37. data/website/assets/chunks/architecture-TIHT7OUA-OUC5PEC7.js +1 -0
  38. data/website/assets/chunks/architectureDiagram-T3A2C74G-6P5MB2DJ.js +36 -0
  39. data/website/assets/chunks/blockDiagram-VBNYF7ZC-ZWINYROZ.js +132 -0
  40. data/website/assets/chunks/c4Diagram-5PPSVZJV-R5RMEAOT.js +10 -0
  41. data/website/assets/chunks/chunk-22DRK7VS.js +1 -0
  42. data/website/assets/chunks/chunk-25REBZ7M.js +2 -0
  43. data/website/assets/chunks/chunk-2RQCUDTG.js +1 -0
  44. data/website/assets/chunks/chunk-34FHPXU4.js +1 -0
  45. data/website/assets/chunks/chunk-57XXJOQN.js +10 -0
  46. data/website/assets/chunks/chunk-6DCNDVBX.js +2 -0
  47. data/website/assets/chunks/chunk-6DHFEWGX.js +1 -0
  48. data/website/assets/chunks/chunk-6VQ47JJS.js +2 -0
  49. data/website/assets/chunks/chunk-7CSIRG7W.js +127 -0
  50. data/website/assets/chunks/chunk-A43YELH5.js +1 -0
  51. data/website/assets/chunks/chunk-AIX6IH4O.js +1 -0
  52. data/website/assets/chunks/chunk-B3K7RH5O.js +88 -0
  53. data/website/assets/chunks/chunk-CBGUD2NP.js +1 -0
  54. data/website/assets/chunks/chunk-D3WOCELE.js +1 -0
  55. data/website/assets/chunks/chunk-D6FURU6X.js +1 -0
  56. data/website/assets/chunks/chunk-DGEGT77F.js +1 -0
  57. data/website/assets/chunks/chunk-DN52R6U7.js +1 -0
  58. data/website/assets/chunks/chunk-E3T3IFAC.js +1 -0
  59. data/website/assets/chunks/chunk-EIBFAW37.js +1 -0
  60. data/website/assets/chunks/chunk-ERWOH47R.js +1 -0
  61. data/website/assets/chunks/chunk-ETOVTYWF.js +1 -0
  62. data/website/assets/chunks/chunk-GHD3PFWA.js +231 -0
  63. data/website/assets/chunks/chunk-GMBKQ7NZ.js +1 -0
  64. data/website/assets/chunks/chunk-GQM25EPP.js +156 -0
  65. data/website/assets/chunks/chunk-GSOU4UTT.js +1 -0
  66. data/website/assets/chunks/chunk-HEMELP7J.js +1 -0
  67. data/website/assets/chunks/chunk-HZ5I5NJO.js +1 -0
  68. data/website/assets/chunks/chunk-IP56FOSI.js +70 -0
  69. data/website/assets/chunks/chunk-L7BMR7WL.js +1 -0
  70. data/website/assets/chunks/chunk-LGUG3SQB.js +1 -0
  71. data/website/assets/chunks/chunk-LOMHZYS2.js +1 -0
  72. data/website/assets/chunks/chunk-MII4VZEB.js +3 -0
  73. data/website/assets/chunks/chunk-MYMJLHQL.js +62 -0
  74. data/website/assets/chunks/chunk-NBH7YOBD.js +15 -0
  75. data/website/assets/chunks/chunk-NCOVM3YF.js +1 -0
  76. data/website/assets/chunks/chunk-OAGTSILX.js +1 -0
  77. data/website/assets/chunks/chunk-OCX5OCIU.js +321 -0
  78. data/website/assets/chunks/chunk-OE3NOVIH.js +1 -0
  79. data/website/assets/chunks/chunk-PLP5XUTJ.js +1 -0
  80. data/website/assets/chunks/chunk-PNOERP6G.js +1 -0
  81. data/website/assets/chunks/chunk-QBPQ7DPE.js +1 -0
  82. data/website/assets/chunks/chunk-RWFFDL53.js +1 -0
  83. data/website/assets/chunks/chunk-SEGIW3MP.js +1 -0
  84. data/website/assets/chunks/chunk-SZDMZNUS.js +1 -0
  85. data/website/assets/chunks/chunk-T2QBAPVT.js +1 -0
  86. data/website/assets/chunks/chunk-UE533OZ7.js +1 -0
  87. data/website/assets/chunks/chunk-UTWOJE2A.js +1 -0
  88. data/website/assets/chunks/chunk-VOCDYA3H.js +1 -0
  89. data/website/assets/chunks/chunk-VYZZPKGK.js +161 -0
  90. data/website/assets/chunks/chunk-WWLZIRS6.js +1 -0
  91. data/website/assets/chunks/chunk-XNXYDBKX.js +32 -0
  92. data/website/assets/chunks/chunk-XQUIO562.js +1 -0
  93. data/website/assets/chunks/chunk-Y2XVFW7T.js +2 -0
  94. data/website/assets/chunks/chunk-YORIST7Z.js +1 -0
  95. data/website/assets/chunks/chunk-Z2WBQPVY.js +206 -0
  96. data/website/assets/chunks/classDiagram-JCYQIIEL-OFDVDDIM.js +1 -0
  97. data/website/assets/chunks/classDiagram-v2-OCEON4UE-E36B6BGQ.js +1 -0
  98. data/website/assets/chunks/cose-bilkent-JH36ORCC-CBAW64AB.js +1 -0
  99. data/website/assets/chunks/cynefin-VYW2F7L2-CBO6DLNV.js +1 -0
  100. data/website/assets/chunks/cynefinDiagram-MW4NZA55-DFINLOUY.js +62 -0
  101. data/website/assets/chunks/dagre-VZM6K2ZE-BZFBSZAH.js +4 -0
  102. data/website/assets/chunks/diagram-7IWD3JNH-XQHTVOU2.js +30 -0
  103. data/website/assets/chunks/diagram-B4RE2ZJO-FTSXBZJR.js +3 -0
  104. data/website/assets/chunks/diagram-LBJQPF4R-ZSZW4OLH.js +24 -0
  105. data/website/assets/chunks/diagram-Q27KOJAE-2IV5TVCJ.js +24 -0
  106. data/website/assets/chunks/diagram-UB23O5K3-D6PDUTFL.js +41 -0
  107. data/website/assets/chunks/ebnfDiagram-BXEA7PRR-C6MAER3M.js +1 -0
  108. data/website/assets/chunks/erDiagram-JOGREHBK-EDAD2H5M.js +85 -0
  109. data/website/assets/chunks/eventmodeling-45OFAUF4-4K577DMF.js +1 -0
  110. data/website/assets/chunks/flowDiagram-UKHOOZJN-545F4YX3.js +1 -0
  111. data/website/assets/chunks/ganttDiagram-PKOTCBZU-7A7EKMY3.js +292 -0
  112. data/website/assets/chunks/gitGraph-TEB2WS4Q-3WUB7WYQ.js +1 -0
  113. data/website/assets/chunks/gitGraphDiagram-DS77QQ5N-A5UWV5FF.js +106 -0
  114. data/website/assets/chunks/info-DKCQHKI2-EOOHI7XS.js +1 -0
  115. data/website/assets/chunks/infoDiagram-6WML65LV-CWIOOWJP.js +2 -0
  116. data/website/assets/chunks/ishikawaDiagram-WSZJBQD7-CEASQVDL.js +70 -0
  117. data/website/assets/chunks/journeyDiagram-NVQOT4AX-LYG3S2AW.js +139 -0
  118. data/website/assets/chunks/kanban-definition-27J2QSJJ-HCC6KAYI.js +89 -0
  119. data/website/assets/chunks/katex-F4MQ6FB3.js +257 -0
  120. data/website/assets/chunks/mindmap-definition-FAOFIHXS-JMGSM3RE.js +96 -0
  121. data/website/assets/chunks/packet-7NZHBO7P-W3SJA3MY.js +1 -0
  122. data/website/assets/chunks/pegDiagram-VL7TDLO6-4JO3Y3Z3.js +1 -0
  123. data/website/assets/chunks/pie-RZYD4A2V-YOIQBTY7.js +1 -0
  124. data/website/assets/chunks/pieDiagram-7S7Q4E2Y-VFQ7SE46.js +39 -0
  125. data/website/assets/chunks/quadrantDiagram-CIZ2JOQS-JISDI2S6.js +7 -0
  126. data/website/assets/chunks/radar-I7S5WNFK-NMKP5AZO.js +1 -0
  127. data/website/assets/chunks/railroad-3IZDKUUU-7MOIV7CI.js +1 -0
  128. data/website/assets/chunks/railroad-abnf-AHOZXSZD-HXNH5SLO.js +1 -0
  129. data/website/assets/chunks/railroad-ebnf-EBAXGLYW-OWDJEWMK.js +1 -0
  130. data/website/assets/chunks/railroad-peg-LSFZ7HO6-ASSINGP5.js +1 -0
  131. data/website/assets/chunks/railroadDiagram-AXF67PYL-AFV5VP5F.js +1 -0
  132. data/website/assets/chunks/requirementDiagram-LRYGKXZP-RNRPEHHN.js +84 -0
  133. data/website/assets/chunks/sankeyDiagram-W5VNT64P-O54R2L3E.js +40 -0
  134. data/website/assets/chunks/sequenceDiagram-SI44F4Z6-42WISKJ7.js +162 -0
  135. data/website/assets/chunks/sizeCapture-X5ZJPWSS-LRN2Q37T.js +1 -0
  136. data/website/assets/chunks/stateDiagram-OKZ733FA-BWIBJXIR.js +1 -0
  137. data/website/assets/chunks/stateDiagram-v2-UEYNNEHI-L73LHL3L.js +1 -0
  138. data/website/assets/chunks/swimlanes-SLNWSIFB-ECOAE7LQ.js +1 -0
  139. data/website/assets/chunks/swimlanesDiagram-ULZ7WXOC-3CKKKFZZ.js +8 -0
  140. data/website/assets/chunks/timeline-definition-Z64GVDOM-HR2OL5G7.js +120 -0
  141. data/website/assets/chunks/treeView-QDETBFTQ-C6D5WFJA.js +1 -0
  142. data/website/assets/chunks/treemap-6X3UGDF4-JFCDMSCA.js +1 -0
  143. data/website/assets/chunks/vennDiagram-T6HMQDX7-62WAAPTF.js +34 -0
  144. data/website/assets/chunks/wardley-OPB4EBWU-RQIOII7I.js +1 -0
  145. data/website/assets/chunks/wardleyDiagram-T6FBY63Y-KUXO3NHV.js +78 -0
  146. data/website/assets/chunks/xychartDiagram-ELKLHX3M-I54W4HJ7.js +7 -0
  147. data/website/assets/color-scheme-bootstrap-4H4ZR4BW.js +1 -0
  148. data/website/assets/docs-3WBBQT6K.css +1 -0
  149. data/website/assets/docs-W3D5DWQF.js +1 -0
  150. data/website/assets/docs-navigation-75ALFYBA.js +1 -0
  151. data/website/assets/graph-QWL54LWD.js +1 -0
  152. data/website/assets/manifest.json +314 -0
  153. data/website/assets/math-7JTH4OWZ.js +21 -0
  154. data/website/assets/mermaid-RXC6URG4.js +10 -0
  155. data/website/assets/minimal-3NPTDWQX.js +1 -0
  156. data/website/assets/minimal-E2VATXXS.css +1 -0
  157. data/website/assets/previews-FMHOX7UX.js +1 -0
  158. data/website/assets/search-BH73JZDU.js +1 -0
  159. data/website/assets/search-worker-SM4IM4J7.js +1 -0
  160. data/website/exe/jekyll-obsidian +5 -0
  161. data/website/lib/jekyll_obsidian/adapter.rb +733 -0
  162. data/website/lib/jekyll_obsidian/built_in_themes.rb +855 -0
  163. data/website/lib/jekyll_obsidian/cli.rb +84 -0
  164. data/website/lib/jekyll_obsidian/content_policy.rb +194 -0
  165. data/website/lib/jekyll_obsidian/destination_registry.rb +39 -0
  166. data/website/lib/jekyll_obsidian/directory_indexes.rb +30 -0
  167. data/website/lib/jekyll_obsidian/external_media.rb +371 -0
  168. data/website/lib/jekyll_obsidian/front_matter.rb +453 -0
  169. data/website/lib/jekyll_obsidian/github_markdown.rb +664 -0
  170. data/website/lib/jekyll_obsidian/html_publication.rb +206 -0
  171. data/website/lib/jekyll_obsidian/initializer.rb +68 -0
  172. data/website/lib/jekyll_obsidian/localized_compiler.rb +1044 -0
  173. data/website/lib/jekyll_obsidian/media_policy.rb +41 -0
  174. data/website/lib/jekyll_obsidian/ofm_scanner.rb +493 -0
  175. data/website/lib/jekyll_obsidian/output_text.rb +13 -0
  176. data/website/lib/jekyll_obsidian/preview.rb +93 -0
  177. data/website/lib/jekyll_obsidian/published_markdown.rb +32 -0
  178. data/website/lib/jekyll_obsidian/raw_html_manifest.rb +66 -0
  179. data/website/lib/jekyll_obsidian/runtime.rb +197 -0
  180. data/website/lib/jekyll_obsidian/site_compilation.rb +147 -0
  181. data/website/lib/jekyll_obsidian/site_navigation.rb +517 -0
  182. data/website/lib/jekyll_obsidian/url_builder.rb +163 -0
  183. data/website/lib/jekyll_obsidian/value_objects.rb +198 -0
  184. data/website/lib/jekyll_obsidian/vault_compiler.rb +2771 -0
  185. data/website/lib/jekyll_obsidian/version.rb +6 -0
  186. data/website/lib/jekyll_obsidian/workspace_layout.rb +104 -0
  187. data/website/lib/jekyll_obsidian.rb +22 -0
  188. data/website/scripts/audit-site.rb +120 -0
  189. data/website/scripts/templates/pages.yml +55 -0
  190. data/website/scripts/verify-site-urls.rb +585 -0
  191. metadata +319 -0
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module JekyllObsidian
6
+ # Owns the serialized contract shared by the compiler, production audit, and
7
+ # URL verifier. Filesystem safety remains the responsibility of each
8
+ # consumer after this structural validation succeeds.
9
+ module RawHtmlManifest
10
+ PATH = "assets/website/raw-html.v1.json"
11
+ ROUTE = "/#{PATH}"
12
+ SCHEMA_VERSION = 1
13
+ KEYS = %w[documents files schema_version].freeze
14
+ DOCUMENT_KEYS = %w[output route].freeze
15
+
16
+ class Invalid < StandardError; end
17
+
18
+ module_function
19
+
20
+ def generate(documents:, files:)
21
+ # Destination preflight owns duplicate-route diagnostics and runs after
22
+ # the manifest object is assembled. Keep generation total so collisions
23
+ # remain structured compiler failures; readers still validate strictly.
24
+ payload = {
25
+ "schema_version" => SCHEMA_VERSION,
26
+ "documents" => documents,
27
+ "files" => files
28
+ }
29
+ "#{JSON.generate(payload)}\n"
30
+ end
31
+
32
+ def parse(content)
33
+ payload = JSON.parse(content)
34
+ validate!(payload)
35
+ payload
36
+ rescue JSON::ParserError => exception
37
+ raise Invalid, "invalid JSON: #{exception.message}"
38
+ end
39
+
40
+ def validate!(payload)
41
+ unless payload.is_a?(Hash) && payload.keys.sort == KEYS && payload["schema_version"] == SCHEMA_VERSION
42
+ raise Invalid, "expected the exact v#{SCHEMA_VERSION} object"
43
+ end
44
+
45
+ documents = payload["documents"]
46
+ files = payload["files"]
47
+ unless documents.is_a?(Array) && files.is_a?(Array) &&
48
+ documents.uniq == documents && files.uniq == files && files.all? { |route| route.is_a?(String) }
49
+ raise Invalid, "documents and files must be unique arrays"
50
+ end
51
+
52
+ documents.each do |document|
53
+ unless document.is_a?(Hash) && document.keys.sort == DOCUMENT_KEYS &&
54
+ document.values.all? { |value| value.is_a?(String) }
55
+ raise Invalid, "document entries must contain string route and output values"
56
+ end
57
+ unless files.include?(document.fetch("output"))
58
+ raise Invalid, "document output is not listed as a file: #{document.fetch('output').inspect}"
59
+ end
60
+ end
61
+
62
+ payload
63
+ end
64
+ private_class_method :validate!
65
+ end
66
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "fileutils"
5
+ require "open3"
6
+ require "pathname"
7
+ require "rbconfig"
8
+ require "tmpdir"
9
+ require "yaml"
10
+ require "jekyll"
11
+ require_relative "version"
12
+ require_relative "workspace_layout"
13
+ require_relative "adapter"
14
+
15
+ module JekyllObsidian
16
+ class Runtime
17
+ class Error < StandardError; end
18
+
19
+ PACKAGE_ROOT = File.realpath(File.expand_path("../..", __dir__))
20
+ CONFIG_PATH = ".github/jekyll-obsidian.yml"
21
+ PUBLIC_KEYS = %w[title description lang url baseurl website].freeze
22
+ SITE_OPTIONS = {
23
+ "safe" => false, "incremental" => false, "disable_disk_cache" => true,
24
+ "strict_front_matter" => true, "permalink" => "pretty", "encoding" => "UTF-8",
25
+ "markdown" => "kramdown", "highlighter" => "none", "plugins" => [], "plugins_dir" => [],
26
+ "liquid" => { "error_mode" => "strict", "strict_filters" => true, "strict_variables" => false }
27
+ }.freeze
28
+
29
+ attr_reader :root, :config_path, :cache_root, :destination, :assets_root
30
+
31
+ def self.discover_root(start = Dir.pwd)
32
+ Pathname.new(File.expand_path(start)).ascend do |path|
33
+ return path.to_s if File.exist?(path.join(CONFIG_PATH)) || File.symlink?(path.join(CONFIG_PATH))
34
+ end
35
+ raise Error, "Missing #{CONFIG_PATH}. Run jekyll-obsidian init from your repository root."
36
+ end
37
+
38
+ def initialize(root:, config_path: nil, assets_root: nil, destination_name: "site")
39
+ @root = File.realpath(root)
40
+ @config_path = config_path || File.join(@root, CONFIG_PATH)
41
+ @assets_root = assets_root || File.join(PACKAGE_ROOT, "assets")
42
+ @cache_root = File.join(@root, WorkspaceLayout::CACHE_DIRECTORY)
43
+ unless destination_name.match?(/\Asite(?:-[A-Za-z0-9][A-Za-z0-9._-]*)?\z/)
44
+ raise Error, "destination name must be site or site-NAME"
45
+ end
46
+ @destination = File.join(@cache_root, destination_name)
47
+ end
48
+
49
+ def configuration
50
+ WorkspaceLayout.assert_path!(@root, @config_path, "configuration")
51
+ raise Error, "configuration must be a regular file" unless File.file?(@config_path)
52
+
53
+ user = YAML.safe_load_file(@config_path, permitted_classes: [Date, Time], aliases: true)
54
+ raise Error, "#{CONFIG_PATH} must be a mapping" unless user.is_a?(Hash)
55
+ unknown = user.keys - PUBLIC_KEYS
56
+ raise Error, "unsupported site configuration: #{unknown.first}" unless unknown.empty?
57
+ if user.key?("website") && !user["website"].is_a?(Hash)
58
+ raise Error, "website must be a mapping"
59
+ end
60
+ defaults = YAML.safe_load_file(File.join(PACKAGE_ROOT, "_config.yml"), aliases: false)
61
+ Jekyll::Utils.deep_merge_hashes(defaults, user)
62
+ rescue Psych::Exception => exception
63
+ raise Error, "Cannot read #{CONFIG_PATH}: #{exception.message}"
64
+ end
65
+
66
+ def source_root(config = configuration)
67
+ source = WorkspaceLayout.relative_source(config.fetch("website").fetch("source"))
68
+ path = File.join(@root, source)
69
+ WorkspaceLayout.assert_path!(@root, path, "website.source")
70
+ raise Error, "website.source must be a directory: #{source}" unless File.directory?(path)
71
+ path
72
+ end
73
+
74
+ def build(url: nil, baseurl: nil, theme: nil, environment: "production", quiet: false)
75
+ config = configuration
76
+ config["website"]["theme"] = theme if theme
77
+ config["url"] = url unless url.nil?
78
+ config["baseurl"] = baseurl unless baseurl.nil?
79
+ validate_urls!(config, environment)
80
+ # Validate content before creating or changing any build state.
81
+ source_root(config)
82
+ with_lock do
83
+ runtime_root = prepare_runtime
84
+ staging = Dir.mktmpdir("site-build.", @cache_root)
85
+ previous_environment = ENV["JEKYLL_ENV"]
86
+ begin
87
+ ENV["JEKYLL_ENV"] = environment
88
+ site_config = Jekyll.configuration(config.merge(SITE_OPTIONS).merge(
89
+ "config" => [], "source" => runtime_root, "destination" => staging,
90
+ "cache_dir" => ".jekyll-cache", "quiet" => quiet,
91
+ "layouts_dir" => "_layouts", "includes_dir" => "_includes",
92
+ "data_dir" => "_data", "collections_dir" => "",
93
+ WorkspaceLayout::CONTEXT_KEY => { "workspace_root" => @root, "assets_root" => @assets_root }
94
+ ))
95
+ Jekyll::Site.new(site_config).process
96
+ audit!(staging, config, quiet:) if environment == "production"
97
+ publish!(staging)
98
+ ensure
99
+ previous_environment.nil? ? ENV.delete("JEKYLL_ENV") : ENV["JEKYLL_ENV"] = previous_environment
100
+ FileUtils.remove_entry(staging) if File.exist?(staging)
101
+ end
102
+ end
103
+ @destination
104
+ end
105
+
106
+ def clean
107
+ return unless File.exist?(@cache_root) || File.symlink?(@cache_root)
108
+
109
+ with_lock do
110
+ Dir.children(@cache_root).each do |entry|
111
+ next if %w[.lock .gitignore].include?(entry)
112
+
113
+ FileUtils.remove_entry(File.join(@cache_root, entry))
114
+ end
115
+ end
116
+ end
117
+
118
+ private
119
+
120
+ def validate_urls!(config, environment)
121
+ origin = config.fetch("url", "").to_s.delete_suffix("/")
122
+ baseurl = config.fetch("baseurl", "").to_s.delete_suffix("/")
123
+ if environment == "production" && origin.empty?
124
+ raise Error, "production builds require --url or url in #{CONFIG_PATH}"
125
+ end
126
+ unless origin.empty? || origin.match?(%r{\Ahttps?://(?:[A-Za-z0-9][A-Za-z0-9.-]*|\[[0-9A-Fa-f:]+\])(?::[0-9]+)?\z})
127
+ raise Error, "url must be an HTTP or HTTPS origin without a path, query, or fragment"
128
+ end
129
+ unless baseurl.empty? || (baseurl.match?(%r{\A(/[A-Za-z0-9._~!$&()*+,;=:@%-]+)+\z}) &&
130
+ baseurl.split("/").none? { |part| %w[. ..].include?(part) })
131
+ raise Error, "baseurl must be empty or a normalized site path such as /project"
132
+ end
133
+ config["url"] = origin
134
+ config["baseurl"] = baseurl
135
+ end
136
+
137
+ def with_lock
138
+ WorkspaceLayout.assert_path!(@root, @cache_root, "application cache", missing: true)
139
+ FileUtils.mkdir_p(@cache_root)
140
+ ignore = File.join(@cache_root, ".gitignore")
141
+ WorkspaceLayout.assert_path!(@root, ignore, "cache ignore file", missing: true)
142
+ File.write(ignore, "*\n") unless File.exist?(ignore)
143
+ lock_path = File.join(@cache_root, ".lock")
144
+ WorkspaceLayout.assert_path!(@root, lock_path, "build lock", missing: true)
145
+ File.open(lock_path, File::RDWR | File::CREAT, 0o600) do |lock|
146
+ raise Error, "another jekyll-obsidian command is using this workspace" unless lock.flock(File::LOCK_EX | File::LOCK_NB)
147
+ yield
148
+ ensure
149
+ lock.flock(File::LOCK_UN)
150
+ end
151
+ end
152
+
153
+ def prepare_runtime
154
+ runtime_root = File.join(@cache_root, "runtime")
155
+ WorkspaceLayout.assert_path!(@root, runtime_root, "Jekyll source", missing: true)
156
+ FileUtils.remove_entry(runtime_root) if File.exist?(runtime_root)
157
+ FileUtils.mkdir_p(runtime_root)
158
+ %w[_layouts _includes].each do |directory|
159
+ FileUtils.cp_r(File.join(PACKAGE_ROOT, directory), File.join(runtime_root, directory))
160
+ end
161
+ runtime_root
162
+ end
163
+
164
+ def audit!(staging, config, quiet:)
165
+ [
166
+ ["audit-site.rb", staging],
167
+ ["verify-site-urls.rb", staging, config.fetch("url"), config.fetch("baseurl")]
168
+ ].each do |script, *arguments|
169
+ output, status = Open3.capture2e(RbConfig.ruby, File.join(PACKAGE_ROOT, "scripts", script), *arguments)
170
+ raise Error, output.strip unless status.success?
171
+ puts output unless quiet
172
+ end
173
+ end
174
+
175
+ def publish!(staging)
176
+ WorkspaceLayout.assert_path!(@root, @destination, "destination", missing: true)
177
+ if File.exist?(@destination) && !File.directory?(@destination)
178
+ raise Error, "destination must be a directory"
179
+ end
180
+ backup = File.join(@cache_root, "site-backup")
181
+ raise Error, "stale site backup exists: #{backup}" if File.exist?(backup) || File.symlink?(backup)
182
+
183
+ moved_previous = false
184
+ begin
185
+ if File.exist?(@destination)
186
+ File.rename(@destination, backup)
187
+ moved_previous = true
188
+ end
189
+ File.rename(staging, @destination)
190
+ rescue SystemCallError
191
+ File.rename(backup, @destination) if moved_previous && !File.exist?(@destination)
192
+ raise
193
+ end
194
+ FileUtils.remove_entry(backup) if moved_previous
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module JekyllObsidian
6
+ # Owns the impure boundary for remote build inputs. VaultCompiler remains a
7
+ # deterministic projection over one immutable snapshot.
8
+ class SiteCompilation
9
+ TRANSLATION_PREFIX = "_translations/"
10
+
11
+ PlannedReference = ImmutableRecord.define(:physical_path, :logical_path, :reference)
12
+
13
+ def self.compile(request, transport: nil, cache_root: nil, manifest: nil)
14
+ new(request, transport:, cache_root:, manifest:).compile
15
+ end
16
+
17
+ def initialize(request, transport:, cache_root:, manifest:)
18
+ @request = request
19
+ @transport = transport
20
+ @cache_root = cache_root
21
+ @manifest = manifest
22
+ end
23
+
24
+ def compile
25
+ baseline = VaultCompiler.compile(@request)
26
+ return baseline unless baseline.success?
27
+
28
+ plan, diagnostics = build_plan(baseline)
29
+ return failure(baseline.diagnostics + diagnostics) unless diagnostics.empty?
30
+ if plan.empty?
31
+ GitHubMarkdown.materialize([], manifest: @manifest) unless @manifest.nil?
32
+ return baseline
33
+ end
34
+
35
+ documents = GitHubMarkdown.materialize(
36
+ plan.map(&:reference),
37
+ transport: @transport,
38
+ cache_root: @cache_root,
39
+ manifest: @manifest
40
+ )
41
+ documents_by_path = plan.each_with_index.to_h do |item, index|
42
+ [item.physical_path, documents.fetch(index)]
43
+ end
44
+ snapshot = Snapshot.new(entries: @request.snapshot.entries.map do |entry|
45
+ document = documents_by_path[entry.path.to_s]
46
+ document ? SnapshotEntry.new(**entry.to_h.merge(external_document: document)) : entry
47
+ end)
48
+ final_request = BuildRequest.new(snapshot: snapshot, config: @request.config)
49
+ result = VaultCompiler.compile(final_request)
50
+ return result unless result.success?
51
+
52
+ BuildSuccess.new(**result.to_h.merge(
53
+ github_markdown_manifest: GitHubMarkdown.dump_manifest(documents)
54
+ ))
55
+ rescue GitHubMarkdown::ManifestError => exception
56
+ failure([diagnostic("github_markdown_manifest_invalid", exception.message)])
57
+ rescue GitHubMarkdown::Error => exception
58
+ failure([diagnostic("github_markdown_fetch_failed", exception.message)])
59
+ end
60
+
61
+ private
62
+
63
+ def build_plan(result)
64
+ published = result.notes.map(&:id).to_set
65
+ portfolio_path = configured_portfolio_path
66
+ physical_note_paths = @request.snapshot.entries.filter_map do |entry|
67
+ path = entry.path.to_s
68
+ path if entry.kind.to_sym == :note && !path.start_with?(TRANSLATION_PREFIX)
69
+ end
70
+ portfolio_index_path = DirectoryIndexes.resolve(physical_note_paths)[portfolio_path]
71
+ locales = Array(result.site_data.dig("website_i18n", "locales")).map { |item| item.fetch("code") }
72
+ default_locale = @request.config.lang.to_s
73
+ plan = []
74
+ diagnostics = []
75
+
76
+ @request.snapshot.entries.sort_by { |entry| entry.path.to_s.b }.each do |entry|
77
+ next unless entry.kind.to_sym == :note
78
+
79
+ physical_path = entry.path.to_s
80
+ locale, logical_path = translated_path(physical_path, locales, default_locale)
81
+ next if physical_path.start_with?(TRANSLATION_PREFIX) && locale.nil?
82
+
83
+ parsed = FrontMatter.parse(physical_path, entry.bytes.to_s)
84
+ reference = parsed.properties["github_markdown"]
85
+ next unless reference
86
+ next if parsed.properties["publish"] == false
87
+ next unless published_note?(published, logical_path, locale)
88
+
89
+ unless logical_path.start_with?("#{portfolio_path}/") && logical_path != portfolio_index_path
90
+ diagnostics << diagnostic(
91
+ "github_markdown_scope",
92
+ "github_markdown is only supported by project wrappers below the configured portfolio path",
93
+ physical_path
94
+ )
95
+ next
96
+ end
97
+ unless parsed.body.strip.empty?
98
+ diagnostics << diagnostic(
99
+ "github_markdown_body_conflict",
100
+ "a github_markdown project wrapper must have an empty local body",
101
+ physical_path
102
+ )
103
+ next
104
+ end
105
+
106
+ plan << PlannedReference.new(
107
+ physical_path: physical_path,
108
+ logical_path: logical_path,
109
+ reference: reference
110
+ )
111
+ end
112
+ [plan.freeze, diagnostics.freeze]
113
+ end
114
+
115
+ def configured_portfolio_path
116
+ navigation = @request.config.navigation
117
+ value = navigation.is_a?(Hash) ? navigation.dig("portfolio", "path") : nil
118
+ (value || "portfolio").to_s.unicode_normalize(:nfc)
119
+ end
120
+
121
+ def translated_path(path, locales, default_locale)
122
+ return [nil, path] unless path.start_with?(TRANSLATION_PREFIX)
123
+
124
+ suffix = path.delete_prefix(TRANSLATION_PREFIX)
125
+ locale, logical = suffix.split("/", 2)
126
+ return [nil, nil] unless logical && locale != default_locale && locales.include?(locale)
127
+
128
+ [locale, logical]
129
+ end
130
+
131
+ def published_note?(published, logical_path, locale)
132
+ return published.include?(logical_path) unless locale
133
+
134
+ published.include?(logical_path) && published.include?("#{locale}:#{logical_path}")
135
+ end
136
+
137
+ def diagnostic(code, message, path = nil)
138
+ Diagnostic.new(severity: :error, code: code, message: message, path: path, span: nil)
139
+ end
140
+
141
+ def failure(diagnostics)
142
+ BuildFailure.new(diagnostics: diagnostics.sort_by do |item|
143
+ [item.path.to_s, item.code.to_s, item.message.to_s]
144
+ end.freeze)
145
+ end
146
+ end
147
+ end