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,733 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "find"
4
+ require "fileutils"
5
+ require "json"
6
+ require "open3"
7
+ require "pathname"
8
+ require "tempfile"
9
+ require "tmpdir"
10
+ require "jekyll"
11
+ require_relative "../jekyll_obsidian"
12
+ require_relative "workspace_layout"
13
+
14
+ module JekyllObsidian
15
+ module Adapter
16
+ BUNDLED_FEATURE_IDS = %w[search graph previews math mermaid].freeze
17
+ CONFIG_KEYS = %w[source syntax_profile theme repository edit_branch content features html i18n comments analytics contacts navigation].freeze
18
+ GIT_FIRST_COMMIT_CACHE_VERSION = 1
19
+ GITHUB_MARKDOWN_MANIFEST_INPUT = "JEKYLL_OBSIDIAN_GITHUB_MARKDOWN_MANIFEST_IN"
20
+ GITHUB_MARKDOWN_MANIFEST_OUTPUT = "JEKYLL_OBSIDIAN_GITHUB_MARKDOWN_MANIFEST_OUT"
21
+ IGNORED_CONTENT_DIRECTORIES = %w[.obsidian .trash].freeze
22
+ STAGING_BASENAME_PATTERN = /\Aprojected-files\.[A-Za-z0-9.-]+\z/
23
+ StagingLease = Struct.new(:parent, :path, keyword_init: true)
24
+
25
+ module SiteProcessCleanup
26
+ def process(...)
27
+ super
28
+ ensure
29
+ Adapter.cleanup_staging(self)
30
+ end
31
+ end
32
+
33
+ class GeneratedPage < Jekyll::PageWithoutAFile
34
+ attr_reader :website_route
35
+
36
+ def initialize(site, output, generated: false)
37
+ @compiler_generated_file = generated
38
+ @website_route = output.route
39
+ directory, filename = self.class.route_parts(output.route)
40
+ super(site, site.source, directory, filename)
41
+ self.content = output.content
42
+ self.data = generated ? {} : output.data.dup
43
+ data["layout"] = nil if generated
44
+ data["permalink"] = output.route
45
+ data["render_with_liquid"] = false
46
+ data["website_generated"] = true
47
+ end
48
+
49
+ # Compiler-owned artifacts are already final bytes. Present an inert
50
+ # source extension to Jekyll's renderer while the permalink retains the
51
+ # public extension (including `.md`).
52
+ def extname
53
+ @compiler_generated_file ? ".website-generated" : super
54
+ end
55
+
56
+ def self.route_parts(route)
57
+ return ["", "index.html"] if route == "/"
58
+
59
+ clean = route.delete_prefix("/")
60
+ if clean.end_with?("/")
61
+ [clean.delete_suffix("/"), "index.html"]
62
+ else
63
+ directory = File.dirname(clean)
64
+ directory = "" if directory == "."
65
+ [directory, File.basename(clean)]
66
+ end
67
+ end
68
+ end
69
+
70
+ class ProjectedStaticFile < Jekyll::StaticFile
71
+ attr_reader :website_route, :source_path, :vault_relative_path
72
+
73
+ def initialize(site, source_root, source_relative_path, route)
74
+ @vault_relative_path = source_relative_path
75
+ @source_path = File.join(source_root, source_relative_path)
76
+ @website_route = route
77
+ super(site, source_root, File.dirname(source_relative_path), File.basename(source_relative_path))
78
+ @relative_path = route
79
+ end
80
+
81
+ def path
82
+ @source_path
83
+ end
84
+
85
+ def url
86
+ @website_route
87
+ end
88
+
89
+ def destination(destination_root)
90
+ @website_destinations ||= {}
91
+ @website_destinations[destination_root] ||= @site.in_dest_dir(
92
+ destination_root,
93
+ Jekyll::URL.unescape_path(@website_route.delete_prefix("/"))
94
+ )
95
+ end
96
+
97
+ def destination_rel_dir
98
+ File.dirname(@website_route)
99
+ end
100
+ end
101
+
102
+ class << self
103
+
104
+ def prepare_site(site)
105
+ site.singleton_class.prepend(SiteProcessCleanup) unless site.singleton_class < SiteProcessCleanup
106
+ website, _layout = normalize_website_configuration(site)
107
+ site.config["website"] = website
108
+ end
109
+
110
+ def generate(site)
111
+ source = site.config.fetch("website").fetch("source")
112
+ layout = resolve_workspace_layout(site, source)
113
+ assert_vault_was_not_read(site, layout)
114
+ result = compile(site, layout)
115
+ log_diagnostics(result)
116
+ unless result.success?
117
+ summary = result.diagnostics.select { |item| item.severity == :error }
118
+ .map { |item| "#{[item.path, item.code].compact.join(":")} (#{item.message})" }
119
+ .join(", ")
120
+ fatal("website compilation failed: #{summary}")
121
+ end
122
+
123
+ staging_root = stage_projected_files(site, layout, result)
124
+ begin
125
+ site.data["website_feed_available"] = result.generated_files.any? { |output| output.route == "/feed.xml" }
126
+ site.data.merge!(result.site_data)
127
+ pages, projected_files = generated_objects(site, result, staging_root)
128
+ app_assets = app_asset_objects(site, layout:, theme: result.theme, features: result.features)
129
+ preflight_collisions(site, pages, projected_files + app_assets)
130
+ site.pages.concat(pages)
131
+ site.static_files.concat(projected_files).concat(app_assets)
132
+ rescue StandardError
133
+ cleanup_staging(site)
134
+ raise
135
+ end
136
+ end
137
+
138
+ def cleanup_staging(site)
139
+ lease = site.remove_instance_variable(:@jekyll_website_staging_lease)
140
+ return unless lease.is_a?(StagingLease)
141
+ return unless File.dirname(lease.path) == lease.parent
142
+ return unless File.basename(lease.path).match?(STAGING_BASENAME_PATTERN)
143
+
144
+ FileUtils.remove_entry(lease.path) if File.exist?(lease.path) || File.symlink?(lease.path)
145
+ rescue NameError
146
+ nil
147
+ end
148
+
149
+ private
150
+
151
+ def normalize_website_configuration(site)
152
+ if site.config.key?("obsidian")
153
+ fatal("obsidian configuration is no longer supported; rename the root mapping to website")
154
+ end
155
+ raw = site.config["website"]
156
+ fatal("website must be a mapping") unless raw.nil? || raw.is_a?(Hash)
157
+ configured = raw || {}
158
+ unknown = configured.keys.map(&:to_s) - CONFIG_KEYS
159
+ fatal("website contains unsupported key: #{unknown.sort.first}") unless unknown.empty?
160
+
161
+ source = configured.key?("source") ? configured["source"] : WorkspaceLayout::DEFAULT_SOURCE
162
+ layout = resolve_workspace_layout(site, source)
163
+ website = {
164
+ "source" => layout.source,
165
+ "syntax_profile" => configured.fetch("syntax_profile", "ofm@1"),
166
+ "theme" => configured.fetch("theme", "minimal"),
167
+ "repository" => configured.fetch("repository", ""),
168
+ "edit_branch" => configured.fetch("edit_branch", "main"),
169
+ "content" => configured["content"],
170
+ "features" => configured["features"],
171
+ "html" => configured["html"],
172
+ "i18n" => configured["i18n"],
173
+ "comments" => configured["comments"],
174
+ "analytics" => configured["analytics"],
175
+ "contacts" => configured["contacts"],
176
+ "navigation" => configured["navigation"]
177
+ }
178
+ [website, layout]
179
+ end
180
+
181
+ def resolve_workspace_layout(site, source)
182
+ WorkspaceLayout.resolve(site:, source:)
183
+ rescue WorkspaceLayout::Invalid => exception
184
+ fatal(exception.message)
185
+ end
186
+
187
+ def assert_vault_was_not_read(site, layout)
188
+ root = layout.source_root
189
+ leaked = []
190
+ site.pages.each { |page| leaked << page.path if path_inside?(page.path, root, site.source) }
191
+ site.static_files.each { |file| leaked << file.path if path_inside?(file.path, root, site.source) }
192
+ site.collections.each_value do |collection|
193
+ collection.docs.each { |document| leaked << document.path if path_inside?(document.path, root, site.source) }
194
+ end
195
+ return if leaked.empty?
196
+
197
+ fatal("website.source entered Jekyll Reader despite exclusion: #{leaked.sort.first}")
198
+ end
199
+
200
+ def path_inside?(candidate, root, site_source)
201
+ return false if candidate.nil? || candidate.to_s.empty?
202
+ expanded = File.expand_path(candidate.to_s, site_source)
203
+ expanded_root = File.expand_path(root)
204
+ return true if path_descendant?(expanded, expanded_root)
205
+ return false unless File.exist?(expanded) || File.symlink?(expanded)
206
+
207
+ resolved = File.realpath(expanded)
208
+ resolved_root = File.realpath(expanded_root)
209
+ path_descendant?(resolved, resolved_root)
210
+ rescue SystemCallError => exception
211
+ fatal("cannot resolve Jekyll Reader input #{candidate}: #{exception.message}")
212
+ end
213
+
214
+ def path_descendant?(candidate, root)
215
+ candidate == root || candidate.start_with?("#{root}#{File::SEPARATOR}")
216
+ end
217
+
218
+ def build_snapshot(layout)
219
+ root = layout.source_root
220
+ git_first_commit_times = git_first_commit_time_map(layout)
221
+ entries = []
222
+ Find.find(root) do |absolute|
223
+ relative = Pathname.new(absolute).relative_path_from(Pathname.new(root)).to_s
224
+ next if relative == "."
225
+
226
+ stat = File.lstat(absolute)
227
+ fatal("vault symlink rejected: #{relative}") if stat.symlink?
228
+ ignored = IGNORED_CONTENT_DIRECTORIES.any? do |directory|
229
+ relative == directory || relative.start_with?("#{directory}#{File::SEPARATOR}")
230
+ end
231
+ if ignored
232
+ Find.prune if stat.directory?
233
+ next
234
+ end
235
+
236
+ next if stat.directory?
237
+ fatal("vault contains a non-regular file: #{relative}") unless stat.file?
238
+
239
+ normalized = relative.tr(File::SEPARATOR, "/")
240
+ kind = if File.extname(normalized).downcase == ".md"
241
+ :note
242
+ elsif File.basename(normalized) == LocalizedCompiler::LOCALE_MANIFEST
243
+ :locale_manifest
244
+ else
245
+ :attachment
246
+ end
247
+ flags = File::RDONLY | (File.const_defined?(:NOFOLLOW) ? File::NOFOLLOW : 0)
248
+ File.open(absolute, flags) do |file|
249
+ pinned = file.stat
250
+ fatal("vault file changed during snapshot: #{relative}") unless pinned.file? && pinned.dev == stat.dev && pinned.ino == stat.ino
251
+ entries << SnapshotEntry.new(
252
+ path: normalized,
253
+ bytes: %i[note locale_manifest].include?(kind) ? file.read : nil,
254
+ kind: kind,
255
+ media_type: kind == :note ? "text/markdown" : MediaPolicy.media_type(normalized),
256
+ size: pinned.size,
257
+ device: pinned.dev,
258
+ inode: pinned.ino,
259
+ mtime_ns: pinned.mtime.to_i * 1_000_000_000 + pinned.mtime.nsec,
260
+ first_committed_at: git_first_commit_times[normalized]
261
+ )
262
+ end
263
+ end
264
+ Snapshot.new(entries: entries.sort_by(&:path))
265
+ rescue Errno::ENOENT => exception
266
+ fatal("vault changed during snapshot: #{exception.message}")
267
+ end
268
+
269
+ def git_first_commit_time_map(layout)
270
+ source = layout.source
271
+ head, _head_error, head_status = Open3.capture3("git", "-C", layout.workspace_root, "rev-parse", "HEAD")
272
+ return {} unless head_status.success?
273
+
274
+ head = head.strip
275
+ cache_path = File.join(layout.application_cache_root, "git-times.json")
276
+ ensure_runtime_directory!(layout.application_cache_root, "application cache")
277
+ cached_bytes = read_regular_cache_file(cache_path, "Git first-commit cache")
278
+ if cached_bytes
279
+ cached = JSON.parse(cached_bytes)
280
+ cached_times = cached["first_committed_at"]
281
+ if cached["version"] == GIT_FIRST_COMMIT_CACHE_VERSION && cached["head"] == head &&
282
+ cached["source"] == source && cached_times.is_a?(Hash) &&
283
+ cached_times.all? { |path, time| path.is_a?(String) && time.is_a?(String) }
284
+ return cached_times
285
+ end
286
+ end
287
+
288
+ command = [
289
+ "git", "-C", layout.workspace_root, "-c", "core.quotePath=false",
290
+ "log", "--format=%x1e%aI", "--name-only", "--", source
291
+ ]
292
+ stdout, _stderr, status = Open3.capture3(*command)
293
+ return {} unless status.success?
294
+
295
+ result = {}
296
+ current_time = nil
297
+ stdout.each_line do |line|
298
+ stripped = line.strip
299
+ next if stripped.empty?
300
+ if stripped.start_with?("\x1e")
301
+ # Git 2.45+ emits Z for UTC while older versions emit +00:00.
302
+ current_time = stripped.delete_prefix("\x1e").sub(/\+00:00\z/, "Z")
303
+ next
304
+ end
305
+ next unless current_time
306
+ prefix = "#{source}/"
307
+ next unless stripped.start_with?(prefix)
308
+
309
+ relative = stripped.delete_prefix(prefix).unicode_normalize(:nfc)
310
+ result[relative] = current_time
311
+ end
312
+ FileUtils.mkdir_p(File.dirname(cache_path))
313
+ temporary = Tempfile.create(["jekyll-obsidian-git-times.", ".tmp"], File.dirname(cache_path))
314
+ temporary_path = temporary.path
315
+ temporary.write(JSON.generate(
316
+ "version" => GIT_FIRST_COMMIT_CACHE_VERSION,
317
+ "head" => head,
318
+ "source" => source,
319
+ "first_committed_at" => result
320
+ ))
321
+ temporary.flush
322
+ temporary.fsync
323
+ temporary.close
324
+ File.rename(temporary_path, cache_path)
325
+ result
326
+ rescue JSON::ParserError, SystemCallError
327
+ {}
328
+ ensure
329
+ temporary.close if defined?(temporary) && temporary && !temporary.closed?
330
+ FileUtils.rm_f(temporary_path) if defined?(temporary_path) && temporary_path
331
+ end
332
+
333
+ def ensure_runtime_directory!(root, label)
334
+ FileUtils.mkdir_p(root)
335
+ stat = File.lstat(root)
336
+ unless stat.directory? && !stat.symlink? && File.realpath(root) == root
337
+ fatal("#{label} must be a non-symlink directory")
338
+ end
339
+ rescue SystemCallError => exception
340
+ fatal("cannot validate #{label}: #{exception.message}")
341
+ end
342
+
343
+ def read_regular_cache_file(path, label)
344
+ return nil unless File.exist?(path) || File.symlink?(path)
345
+
346
+ before = File.lstat(path)
347
+ fatal("#{label} must be a non-symlink regular file") unless before.file? && !before.symlink?
348
+ flags = File::RDONLY | (File.const_defined?(:NOFOLLOW) ? File::NOFOLLOW : 0)
349
+ File.open(path, flags) do |file|
350
+ opened = file.stat
351
+ unless opened.file? && opened.dev == before.dev && opened.ino == before.ino
352
+ fatal("#{label} changed while opening")
353
+ end
354
+ file.read
355
+ end
356
+ rescue SystemCallError => exception
357
+ fatal("cannot read #{label}: #{exception.message}")
358
+ end
359
+
360
+ def repository_identity(site, layout)
361
+ explicit = site.config.dig("website", "repository").to_s.strip
362
+ return explicit unless explicit.empty?
363
+ environment = ENV.fetch("GITHUB_REPOSITORY", "").strip
364
+ return environment unless environment.empty?
365
+
366
+ stdout, _stderr, status = Open3.capture3(
367
+ "git", "-C", layout.workspace_root, "config", "--get", "remote.origin.url"
368
+ )
369
+ return nil unless status.success?
370
+ remote = stdout.strip
371
+ match = remote.match(%r{(?:github\.com[:/])([^/\s]+/[^/\s]+?)(?:\.git)?\z}i)
372
+ match && match[1]
373
+ rescue SystemCallError
374
+ nil
375
+ end
376
+
377
+ def compile(site, layout)
378
+ repository = repository_identity(site, layout)
379
+ Jekyll.logger.warn("Website:", "repository identity unavailable; GitHub collaboration links are hidden") unless repository
380
+ website = site.config.fetch("website")
381
+ request = BuildRequest.new(
382
+ snapshot: build_snapshot(layout),
383
+ config: BuildConfig.new(
384
+ title: site.config["title"].to_s,
385
+ description: site.config["description"].to_s,
386
+ lang: site.config.fetch("lang", "en").to_s,
387
+ url: site.config["url"].to_s,
388
+ baseurl: site.config["baseurl"].to_s,
389
+ source: layout.source,
390
+ syntax_profile: website.fetch("syntax_profile"),
391
+ theme: website.fetch("theme"),
392
+ content: website.fetch("content"),
393
+ features: website.fetch("features"),
394
+ html: website.fetch("html"),
395
+ i18n: website.fetch("i18n"),
396
+ comments: website.fetch("comments"),
397
+ analytics: website.fetch("analytics"),
398
+ contacts: website.fetch("contacts"),
399
+ navigation: website.fetch("navigation"),
400
+ repository: repository.to_s,
401
+ edit_branch: website.fetch("edit_branch"),
402
+ environment: ENV.fetch("JEKYLL_ENV", "development")
403
+ )
404
+ )
405
+ ensure_runtime_directory!(layout.application_cache_root, "Application cache")
406
+ manifest_input = github_markdown_manifest_path(layout, GITHUB_MARKDOWN_MANIFEST_INPUT)
407
+ manifest = manifest_input && read_regular_cache_file(manifest_input, "GitHub Markdown manifest")
408
+ fatal("GitHub Markdown manifest does not exist") if manifest_input && manifest.nil?
409
+ result = SiteCompilation.compile(
410
+ request,
411
+ transport: GitHubMarkdown::HttpTransport.new,
412
+ cache_root: layout.application_cache_root,
413
+ manifest: manifest
414
+ )
415
+ manifest_output = github_markdown_manifest_path(layout, GITHUB_MARKDOWN_MANIFEST_OUTPUT)
416
+ if result.success? && manifest_output
417
+ content = result.github_markdown_manifest || GitHubMarkdown.dump_manifest([])
418
+ write_github_markdown_manifest(manifest_output, content)
419
+ end
420
+ result
421
+ end
422
+
423
+ def github_markdown_manifest_path(layout, environment_key)
424
+ raw = ENV.fetch(environment_key, "").to_s
425
+ return nil if raw.empty?
426
+
427
+ path = File.expand_path(raw, layout.workspace_root)
428
+ unless File.dirname(path) == layout.application_cache_root &&
429
+ File.basename(path).match?(/\A[A-Za-z0-9][A-Za-z0-9._-]*\.json\z/)
430
+ fatal("#{environment_key} must name a JSON file directly inside the application cache")
431
+ end
432
+ path
433
+ end
434
+
435
+ def write_github_markdown_manifest(path, content)
436
+ if File.exist?(path) || File.symlink?(path)
437
+ stat = File.lstat(path)
438
+ fatal("GitHub Markdown manifest output must be a non-symlink regular file") unless stat.file? && !stat.symlink?
439
+ end
440
+ temporary = Tempfile.create(["github-markdown-manifest.", ".tmp"], File.dirname(path))
441
+ temporary_path = temporary.path
442
+ temporary.binmode
443
+ temporary.write(content)
444
+ temporary.flush
445
+ temporary.fsync
446
+ temporary.close
447
+ File.rename(temporary_path, path)
448
+ rescue SystemCallError => exception
449
+ fatal("cannot write GitHub Markdown manifest: #{exception.message}")
450
+ ensure
451
+ temporary.close if defined?(temporary) && temporary && !temporary.closed?
452
+ FileUtils.rm_f(temporary_path) if defined?(temporary_path) && temporary_path
453
+ end
454
+
455
+ def stage_projected_files(site, layout, result)
456
+ cleanup_staging(site)
457
+ FileUtils.mkdir_p(layout.application_cache_root)
458
+ validate_application_cache_root!(layout)
459
+ staging_root = Dir.mktmpdir("projected-files.", layout.application_cache_root)
460
+ vault_root = layout.source_root
461
+ result.projected_files.each do |output|
462
+ source_path = File.join(vault_root, output.source_path)
463
+ destination = File.join(staging_root, output.source_path)
464
+ FileUtils.mkdir_p(File.dirname(destination))
465
+ flags = File::RDONLY | (File.const_defined?(:NOFOLLOW) ? File::NOFOLLOW : 0)
466
+ File.open(source_path, flags) do |file|
467
+ stat = file.stat
468
+ actual_mtime_ns = stat.mtime.to_i * 1_000_000_000 + stat.mtime.nsec
469
+ expected = [output.device, output.inode, output.size, output.mtime_ns]
470
+ actual = [stat.dev, stat.ino, stat.size, actual_mtime_ns]
471
+ fatal("projected file changed after compilation: #{output.source_path}") unless stat.file? && actual == expected
472
+ File.open(destination, File::WRONLY | File::CREAT | File::EXCL, 0o644) { |target| IO.copy_stream(file, target) }
473
+ end
474
+ end
475
+ site.instance_variable_set(
476
+ :@jekyll_website_staging_lease,
477
+ StagingLease.new(parent: layout.application_cache_root, path: staging_root).freeze
478
+ )
479
+ staging_root
480
+ rescue StandardError
481
+ FileUtils.remove_entry(staging_root) if staging_root && File.exist?(staging_root)
482
+ raise
483
+ end
484
+
485
+ def generated_objects(site, result, staging_root)
486
+ pages = result.pages.map { |output| GeneratedPage.new(site, output) }
487
+ generated = result.generated_files.map do |output|
488
+ page_output = PageOutput.new(route: output.route, content: output.content, data: {})
489
+ GeneratedPage.new(site, page_output, generated: true)
490
+ end
491
+ assets = result.projected_files.map do |output|
492
+ ProjectedStaticFile.new(site, staging_root, output.source_path, output.route)
493
+ end
494
+ [pages + generated, assets]
495
+ end
496
+
497
+ def app_asset_objects(site, layout:, theme:, features:)
498
+ root = layout.application_assets_root
499
+ validate_application_asset_root!(layout, root)
500
+ manifest_path = File.join(root, "manifest.json")
501
+ unless File.file?(manifest_path)
502
+ fatal("application asset manifest is missing: #{manifest_path}") unless Jekyll.env == "development"
503
+
504
+ Jekyll.logger.warn("Website:", "application asset manifest is missing; frontend assets are unavailable")
505
+ return []
506
+ end
507
+ validate_application_asset_file!(
508
+ manifest_path,
509
+ cache_root: root,
510
+ label: "application asset manifest"
511
+ )
512
+
513
+ manifest = JSON.parse(File.read(manifest_path, encoding: "UTF-8"))
514
+ fatal("asset manifest schema is unsupported") unless manifest["schema_version"] == 1
515
+ entries = manifest["entries"]
516
+ fatal("asset manifest entries must be a mapping") unless entries.is_a?(Hash)
517
+ active_entry = entries[theme]
518
+ fatal("asset manifest has no entry for active theme: #{theme}") unless active_entry.is_a?(Hash)
519
+ validate_manifest_entry!(active_entry, "entries.#{theme}")
520
+
521
+ manifest_features = manifest.fetch("features", {})
522
+ fatal("asset manifest features must be a mapping") unless manifest_features.is_a?(Hash)
523
+ files = active_entry.fetch("files").dup
524
+ features.each do |feature, enabled|
525
+ next unless enabled && BUNDLED_FEATURE_IDS.include?(feature)
526
+
527
+ entry = manifest_features[feature]
528
+ fatal("asset manifest is missing enabled bundle feature: features.#{feature}") unless entry
529
+ validate_manifest_file_list!(entry, "features.#{feature}")
530
+ files.concat(entry.fetch("files"))
531
+ end
532
+ files = files.uniq.sort
533
+ allowlist = manifest["files"]
534
+ if allowlist
535
+ fatal("asset manifest files must be an array") unless allowlist.is_a?(Array)
536
+ allowlist.each { |relative| validate_manifest_path!(relative) }
537
+ outside_allowlist = files - allowlist
538
+ fatal("active asset is absent from manifest files: #{outside_allowlist.first}") unless outside_allowlist.empty?
539
+ end
540
+
541
+ site.data["website_assets"] = manifest
542
+ files.map do |relative|
543
+ validate_manifest_path!(relative)
544
+ absolute = File.join(root, relative)
545
+ validate_application_asset_file!(
546
+ absolute,
547
+ cache_root: root,
548
+ label: "application asset #{relative}"
549
+ )
550
+ ProjectedStaticFile.new(site, root, relative, "/assets/website/#{relative}")
551
+ end
552
+ rescue JSON::ParserError => exception
553
+ fatal("invalid asset manifest: #{exception.message}")
554
+ rescue SystemCallError => exception
555
+ fatal("cannot load application assets: #{exception.message}")
556
+ end
557
+
558
+ def validate_application_cache_root!(layout)
559
+ root = layout.application_cache_root
560
+ stat = File.lstat(root)
561
+ fatal("application cache must be a non-symlink directory") unless stat.directory? && !stat.symlink?
562
+ rescue SystemCallError => exception
563
+ fatal("cannot validate application cache: #{exception.message}")
564
+ end
565
+
566
+ def validate_application_asset_root!(layout, root)
567
+ expanded_root = File.expand_path(root)
568
+ fatal("unexpected application asset root") unless expanded_root == layout.application_assets_root
569
+
570
+ return unless File.exist?(expanded_root) || File.symlink?(expanded_root)
571
+
572
+ stat = File.lstat(expanded_root)
573
+ if stat.symlink? || File.realpath(expanded_root) != expanded_root
574
+ fatal("application assets path contains a symbolic link")
575
+ end
576
+ fatal("application assets must be a directory") unless stat.directory?
577
+ rescue SystemCallError => exception
578
+ fatal("cannot validate application asset cache: #{exception.message}")
579
+ end
580
+
581
+ def validate_application_asset_file!(path, cache_root:, label:)
582
+ expanded = File.expand_path(path)
583
+ expanded_cache_root = File.expand_path(cache_root)
584
+ unless path_descendant?(expanded, expanded_cache_root) && expanded != expanded_cache_root
585
+ fatal("#{label} escapes the application asset cache")
586
+ end
587
+
588
+ relative = Pathname.new(expanded).relative_path_from(Pathname.new(expanded_cache_root))
589
+ cursor = expanded_cache_root
590
+ parts = relative.each_filename.to_a
591
+ parts.each_with_index do |part, index|
592
+ cursor = File.join(cursor, part)
593
+ stat = File.lstat(cursor)
594
+ fatal("#{label} path contains a symbolic link: #{cursor}") if stat.symlink?
595
+ if index == parts.length - 1
596
+ fatal("#{label} is not a regular file") unless stat.file?
597
+ else
598
+ fatal("#{label} path contains a non-directory: #{cursor}") unless stat.directory?
599
+ end
600
+ end
601
+ rescue ArgumentError
602
+ fatal("#{label} escapes the application asset cache")
603
+ end
604
+
605
+ def validate_manifest_entry!(entry, location)
606
+ fatal("asset manifest #{location} must be a mapping") unless entry.is_a?(Hash)
607
+ js = entry["js"]
608
+ fatal("asset manifest #{location}.js is invalid") unless js.is_a?(String)
609
+ validate_manifest_path!(js)
610
+ color_scheme = entry["color_scheme"]
611
+ fatal("asset manifest #{location}.color_scheme is invalid") unless color_scheme.is_a?(String)
612
+ validate_manifest_path!(color_scheme)
613
+ if entry.key?("css")
614
+ fatal("asset manifest #{location}.css is invalid") unless entry["css"].is_a?(String)
615
+ validate_manifest_path!(entry["css"])
616
+ end
617
+ validate_manifest_file_list!(entry, location)
618
+ fatal("asset manifest #{location}.files must contain its js") unless entry.fetch("files").include?(js)
619
+ unless entry.fetch("files").include?(color_scheme)
620
+ fatal("asset manifest #{location}.files must contain its color scheme bootstrap")
621
+ end
622
+ if entry.key?("css") && !entry.fetch("files").include?(entry["css"])
623
+ fatal("asset manifest #{location}.files must contain its css")
624
+ end
625
+ end
626
+
627
+ def validate_manifest_file_list!(entry, location)
628
+ fatal("asset manifest #{location} must be a mapping") unless entry.is_a?(Hash)
629
+ files = entry["files"]
630
+ fatal("asset manifest #{location}.files must be an array") unless files.is_a?(Array)
631
+ files.each { |relative| validate_manifest_path!(relative) }
632
+ end
633
+
634
+ def validate_manifest_path!(path)
635
+ fatal("asset manifest contains an unsafe path") unless safe_relative_path?(path)
636
+ end
637
+
638
+ def safe_relative_path?(path)
639
+ path.is_a?(String) && !path.empty? && !path.include?("\\") && !path.include?("\0") &&
640
+ !Pathname.new(path).absolute? && Pathname.new(path).cleanpath.to_s == path &&
641
+ path.split("/").none? { |part| part.empty? || part == "." || part == ".." }
642
+ rescue ArgumentError
643
+ false
644
+ end
645
+
646
+ def preflight_collisions(site, pages, static_files)
647
+ registry = {}
648
+ site.pages.each { |page| register_output!(registry, site, page, page.url, "Jekyll page #{page.path}") }
649
+ site.static_files.each { |file| register_output!(registry, site, file, file.url, "Jekyll static file #{file.path}") }
650
+ site.collections.each_value do |collection|
651
+ collection.docs.each do |document|
652
+ next unless document.write?
653
+
654
+ register_output!(registry, site, document, document.url, "Jekyll document #{document.path}")
655
+ end
656
+ end
657
+ pages.each { |page| register_output!(registry, site, page, page.website_route, "website output") }
658
+ static_files.each do |file|
659
+ owner = if file.is_a?(ProjectedStaticFile)
660
+ "website projected source #{file.vault_relative_path}"
661
+ else
662
+ "website asset"
663
+ end
664
+ register_output!(registry, site, file, file.website_route, owner)
665
+ end
666
+ end
667
+
668
+ def register_output!(registry, site, output, route, owner)
669
+ validate_output_route!(route)
670
+ key = destination_collision_key(site, output.destination(site.dest))
671
+ if registry.key?(key)
672
+ fatal("output route collision at #{route} (#{owner}; already owned by #{registry[key]})")
673
+ end
674
+ registry[key] = owner
675
+ end
676
+
677
+ def validate_output_route!(route)
678
+ UrlBuilder.new(origin: "", baseurl: "").collision_key(route.to_s)
679
+ rescue ArgumentError => exception
680
+ fatal("unsafe output route #{route.inspect}: #{exception.message}")
681
+ end
682
+
683
+ def destination_collision_key(site, destination)
684
+ root = File.expand_path(site.dest)
685
+ expanded = File.expand_path(destination.to_s)
686
+ unless path_descendant?(expanded, root) && expanded != root
687
+ fatal("output destination escapes the configured destination: #{destination.inspect}")
688
+ end
689
+
690
+ Pathname.new(expanded).relative_path_from(Pathname.new(root)).to_s
691
+ .tr(File::SEPARATOR, "/")
692
+ .unicode_normalize(:nfc)
693
+ .downcase(:fold)
694
+ rescue ArgumentError => exception
695
+ fatal("unsafe output destination #{destination.inspect}: #{exception.message}")
696
+ end
697
+
698
+ def log_diagnostics(result)
699
+ result.diagnostics.each do |diagnostic|
700
+ label = [diagnostic.path, diagnostic.code].compact.join(":")
701
+ if diagnostic.severity == :error
702
+ Jekyll.logger.error("Website #{label}:", diagnostic.message)
703
+ else
704
+ Jekyll.logger.warn("Website #{label}:", diagnostic.message)
705
+ end
706
+ end
707
+ end
708
+
709
+ def fatal(message)
710
+ raise Jekyll::Errors::FatalException, message
711
+ end
712
+ end
713
+
714
+ class Generator < Jekyll::Generator
715
+ safe false
716
+ priority :highest
717
+
718
+ def generate(site)
719
+ return unless site.config[WorkspaceLayout::CONTEXT_KEY]
720
+
721
+ Adapter.generate(site)
722
+ end
723
+ end
724
+ end
725
+ end
726
+
727
+ Jekyll::Hooks.register :site, :after_init do |site|
728
+ JekyllObsidian::Adapter.prepare_site(site) if site.config[JekyllObsidian::WorkspaceLayout::CONTEXT_KEY]
729
+ end
730
+
731
+ Jekyll::Hooks.register :site, :post_write do |site|
732
+ JekyllObsidian::Adapter.cleanup_staging(site)
733
+ end