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,664 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "tempfile"
5
+ require "base64"
6
+ require "digest"
7
+ require "json"
8
+ require "net/http"
9
+ require "timeout"
10
+
11
+ require_relative "value_objects"
12
+ require_relative "output_text"
13
+
14
+ module JekyllObsidian
15
+ module GitHubMarkdown
16
+ REPOSITORY_COMPONENT = /\A[A-Za-z0-9][A-Za-z0-9._-]*\z/
17
+ COMMIT = /\A[0-9a-f]{40}\z/i
18
+ INVALID_REF_CHARACTER = /[\x00-\x20\x7f~^:?*\[\\]/
19
+ ENCODED_SEPARATOR = /%(?:2f|5c)/i
20
+ MARKDOWN_EXTENSIONS = %w[.md .markdown].freeze
21
+ MAX_FILE_BYTES = 1024 * 1024
22
+ # A distinct moving reference costs one resolve and one contents request.
23
+ # Twenty-eight leaves four requests inside GitHub's 60-request anonymous hourly budget.
24
+ MAX_REFERENCES = 28
25
+ MAX_TOTAL_BYTES = 8 * 1024 * 1024
26
+ MAX_HTTP_RESPONSE_BYTES = 2 * 1024 * 1024
27
+ MAX_MANIFEST_BYTES = 64 * 1024 * 1024
28
+ MAX_RETRY_WAIT_SECONDS = 2
29
+ MANIFEST_VERSION = 1
30
+
31
+ Reference = ImmutableRecord.define(:repository, :ref, :path)
32
+ Document = ImmutableRecord.define(
33
+ :repository,
34
+ :requested_ref,
35
+ :path,
36
+ :resolved_commit,
37
+ :source_url,
38
+ :digest,
39
+ :markdown
40
+ )
41
+ HttpResponse = ImmutableRecord.define(:status, :headers, :body)
42
+
43
+ class Error < StandardError; end
44
+ class Invalid < Error; end
45
+ class FetchError < Error; end
46
+ class CacheError < Error; end
47
+ class ManifestError < Error; end
48
+
49
+ # The adapter owns this runtime directory. These checks reject pre-existing
50
+ # links and non-regular entries and support cooperating concurrent builds;
51
+ # like the other application caches, it is not a capability boundary
52
+ # against a same-user process replacing directories during a system call.
53
+ class Cache
54
+ def initialize(root)
55
+ expanded = File.expand_path(root)
56
+ stat = File.lstat(expanded)
57
+ unless stat.directory? && !stat.symlink?
58
+ raise CacheError, "GitHub Markdown cache root must be a non-symlink directory"
59
+ end
60
+ @root = File.realpath(expanded)
61
+ validate_directory!(@root, "GitHub Markdown cache root")
62
+ rescue TypeError
63
+ raise CacheError, "GitHub Markdown cache root must be a directory path"
64
+ rescue Errno::ENOENT
65
+ raise CacheError, "GitHub Markdown cache root does not exist"
66
+ rescue SystemCallError => exception
67
+ raise CacheError, "cannot validate GitHub Markdown cache root: #{exception.message}"
68
+ end
69
+
70
+ def read(repository:, commit:, path:)
71
+ target = target_path(repository, commit, path)
72
+ return nil unless File.exist?(target) || File.symlink?(target)
73
+
74
+ ensure_tree!(File.dirname(target))
75
+ before = File.lstat(target)
76
+ unless before.file? && !before.symlink?
77
+ raise CacheError, "GitHub Markdown cache entry must be a non-symlink regular file"
78
+ end
79
+ flags = File::RDONLY | File::BINARY |
80
+ (File.const_defined?(:NOFOLLOW) ? File::NOFOLLOW : 0) |
81
+ (File.const_defined?(:SHARE_DELETE) ? File::SHARE_DELETE : 0)
82
+ File.open(target, flags) do |file|
83
+ opened = file.stat
84
+ unless opened.file? && opened.dev == before.dev && opened.ino == before.ino
85
+ raise CacheError, "GitHub Markdown cache entry changed while opening"
86
+ end
87
+ file.read
88
+ end
89
+ rescue SystemCallError => exception
90
+ raise CacheError, "cannot read GitHub Markdown cache: #{exception.message}"
91
+ end
92
+
93
+ def write(repository:, commit:, path:, markdown:)
94
+ target = target_path(repository, commit, path)
95
+ parent = ensure_tree!(File.dirname(target))
96
+ existing = read(repository: repository, commit: commit, path: path)
97
+ return existing if existing
98
+
99
+ temporary = Tempfile.create(["github-markdown.", ".tmp"], parent)
100
+ temporary_path = temporary.path
101
+ temporary.binmode
102
+ temporary.write(markdown)
103
+ temporary.flush
104
+ temporary.fsync
105
+ temporary.close
106
+ begin
107
+ # Publish the complete inode without replacing an entry another
108
+ # writer installed after the initial cache read.
109
+ File.link(temporary_path, target)
110
+ rescue Errno::EEXIST, Errno::EACCES => exception
111
+ concurrent = read(repository: repository, commit: commit, path: path)
112
+ return concurrent if concurrent == markdown
113
+ raise exception
114
+ end
115
+ markdown
116
+ rescue SystemCallError => exception
117
+ raise CacheError, "cannot write GitHub Markdown cache: #{exception.message}"
118
+ ensure
119
+ temporary.close if defined?(temporary) && temporary && !temporary.closed?
120
+ File.unlink(temporary_path) if defined?(temporary_path) && temporary_path && File.exist?(temporary_path)
121
+ end
122
+
123
+ private
124
+
125
+ def target_path(repository, commit, path)
126
+ digest = Digest::SHA256.hexdigest([repository, commit, path].join("\0"))
127
+ File.join(@root, "github-markdown", "v1", commit, digest[0, 2], "#{digest}.md")
128
+ end
129
+
130
+ def ensure_tree!(target)
131
+ prefix = "#{@root}#{File::SEPARATOR}"
132
+ relative = target.delete_prefix(prefix)
133
+ if relative == target || relative.split(File::SEPARATOR).any? { |part| part.empty? || part == "." || part == ".." }
134
+ raise CacheError, "GitHub Markdown cache entry escapes its cache root"
135
+ end
136
+
137
+ cursor = @root
138
+ relative.split(File::SEPARATOR).each do |part|
139
+ cursor = File.join(cursor, part)
140
+ unless File.exist?(cursor) || File.symlink?(cursor)
141
+ begin
142
+ Dir.mkdir(cursor)
143
+ rescue Errno::EEXIST
144
+ # Another materializer installed this component. Revalidate it
145
+ # through the same non-symlink policy before continuing.
146
+ end
147
+ end
148
+ validate_directory!(cursor, "GitHub Markdown cache directory")
149
+ end
150
+ cursor
151
+ end
152
+
153
+ def validate_directory!(path, label)
154
+ stat = File.lstat(path)
155
+ unless stat.directory? && !stat.symlink? && File.realpath(path) == path
156
+ raise CacheError, "#{label} must be a non-symlink directory"
157
+ end
158
+ rescue Errno::ENOENT
159
+ raise CacheError, "#{label} does not exist"
160
+ rescue SystemCallError => exception
161
+ raise CacheError, "cannot validate #{label}: #{exception.message}"
162
+ end
163
+ end
164
+
165
+ class MemoryTransport
166
+ attr_reader :resolve_calls, :fetch_calls
167
+
168
+ def initialize(commits:, files:)
169
+ @commits = commits
170
+ @files = files
171
+ @resolve_calls = []
172
+ @fetch_calls = []
173
+ end
174
+
175
+ def resolve_commit(repository:, ref:)
176
+ key = [repository, ref]
177
+ @resolve_calls << key
178
+ @commits.fetch(key) { raise FetchError, "GitHub ref not found: #{repository}@#{ref}" }
179
+ end
180
+
181
+ def fetch_markdown(repository:, commit:, path:)
182
+ key = [repository, commit, path]
183
+ @fetch_calls << key
184
+ @files.fetch(key) { raise FetchError, "GitHub file not found: #{repository}/#{path}@#{commit}" }
185
+ end
186
+ end
187
+
188
+ class NetworkError < FetchError; end
189
+
190
+ class NetHttpRequester
191
+ def get(uri:, headers:, open_timeout:, read_timeout:)
192
+ unless uri.is_a?(URI::HTTPS) && uri.host == "api.github.com" && uri.port == 443 && uri.userinfo.nil?
193
+ raise FetchError, "GitHub HTTP transport only connects to https://api.github.com"
194
+ end
195
+
196
+ request = Net::HTTP::Get.new(uri)
197
+ headers.each { |name, value| request[name] = value }
198
+ body = +""
199
+ response_headers = nil
200
+ status = nil
201
+ http = Net::HTTP.new(uri.host, uri.port)
202
+ http.use_ssl = true
203
+ http.open_timeout = open_timeout
204
+ http.read_timeout = read_timeout
205
+ http.write_timeout = read_timeout
206
+ http.request(request) do |response|
207
+ status = response.code.to_i
208
+ response_headers = response.each_header.to_h
209
+ response.read_body do |chunk|
210
+ body << chunk
211
+ if body.bytesize > MAX_HTTP_RESPONSE_BYTES
212
+ raise FetchError, "GitHub API response exceeds the 2 MiB transport limit"
213
+ end
214
+ end
215
+ end
216
+ HttpResponse.new(status: status, headers: response_headers, body: body)
217
+ rescue Timeout::Error, IOError, EOFError, SocketError, SystemCallError, OpenSSL::SSL::SSLError => exception
218
+ raise NetworkError, "GitHub API request failed: #{exception.class}"
219
+ end
220
+ end
221
+
222
+ class HttpTransport
223
+ API_ROOT = "https://api.github.com"
224
+ DEFAULT_OPEN_TIMEOUT = 5
225
+ DEFAULT_READ_TIMEOUT = 15
226
+
227
+ def initialize(
228
+ requester: NetHttpRequester.new,
229
+ open_timeout: DEFAULT_OPEN_TIMEOUT,
230
+ read_timeout: DEFAULT_READ_TIMEOUT,
231
+ sleeper: ->(seconds) { sleep(seconds) }
232
+ )
233
+ unless open_timeout.is_a?(Numeric) && open_timeout.positive? &&
234
+ read_timeout.is_a?(Numeric) && read_timeout.positive?
235
+ raise Invalid, "GitHub HTTP timeouts must be positive numbers"
236
+ end
237
+
238
+ @requester = requester
239
+ @open_timeout = open_timeout
240
+ @read_timeout = read_timeout
241
+ @sleeper = sleeper
242
+ @headers = {
243
+ "Accept" => "application/vnd.github+json",
244
+ "X-GitHub-Api-Version" => "2026-03-10",
245
+ "User-Agent" => "jekyll-obsidian"
246
+ }
247
+ @headers.freeze
248
+ end
249
+
250
+ def resolve_commit(repository:, ref:)
251
+ encoded_ref = URI.encode_uri_component(ref)
252
+ response = request_json("#{API_ROOT}/repos/#{repository}/commits/#{encoded_ref}")
253
+ sha = response["sha"]
254
+ raise FetchError, "GitHub commit response is missing sha" unless sha.is_a?(String)
255
+
256
+ sha
257
+ end
258
+
259
+ def fetch_markdown(repository:, commit:, path:)
260
+ encoded_path = path.split("/").map { |part| URI.encode_uri_component(part) }.join("/")
261
+ query = URI.encode_www_form("ref" => commit)
262
+ response = request_json("#{API_ROOT}/repos/#{repository}/contents/#{encoded_path}?#{query}")
263
+ unless response["type"] == "file" && response["encoding"] == "base64" && response["content"].is_a?(String)
264
+ raise FetchError, "GitHub contents response is not a Base64 file"
265
+ end
266
+
267
+ Base64.strict_decode64(response["content"].gsub(/[\t\r\n ]/, ""))
268
+ rescue ArgumentError
269
+ raise FetchError, "GitHub contents response contains invalid Base64"
270
+ end
271
+
272
+ private
273
+
274
+ def request_json(raw_uri)
275
+ attempts = 0
276
+ loop do
277
+ attempts += 1
278
+ begin
279
+ response = @requester.get(
280
+ uri: URI.parse(raw_uri),
281
+ headers: @headers,
282
+ open_timeout: @open_timeout,
283
+ read_timeout: @read_timeout
284
+ )
285
+ rescue NetworkError
286
+ raise if attempts > 1
287
+ next
288
+ end
289
+ unless response.is_a?(HttpResponse) && response.status.is_a?(Integer) &&
290
+ response.headers.is_a?(Hash) && response.body.is_a?(String)
291
+ raise FetchError, "GitHub HTTP requester returned an invalid response"
292
+ end
293
+
294
+ if response.status == 200
295
+ begin
296
+ parsed = JSON.parse(response.body)
297
+ rescue JSON::ParserError
298
+ raise FetchError, "GitHub API returned invalid JSON"
299
+ end
300
+ raise FetchError, "GitHub API response must be a JSON object" unless parsed.is_a?(Hash)
301
+
302
+ return parsed
303
+ end
304
+ retry_after = response.headers["retry-after"].to_s.to_i
305
+ rate_limited = response.status == 429 ||
306
+ (response.status == 403 && (retry_after.positive? || response.headers["x-ratelimit-remaining"].to_s == "0"))
307
+ if rate_limited
308
+ if attempts == 1 && retry_after.between?(1, MAX_RETRY_WAIT_SECONDS)
309
+ @sleeper.call(retry_after)
310
+ next
311
+ end
312
+ suffix = if retry_after.positive?
313
+ "; retry after #{retry_after} seconds"
314
+ elsif !response.headers["x-ratelimit-reset"].to_s.empty?
315
+ "; rate limit resets at #{response.headers["x-ratelimit-reset"]} UTC epoch seconds"
316
+ else
317
+ ""
318
+ end
319
+ raise FetchError, "GitHub API returned HTTP #{response.status}#{suffix}"
320
+ end
321
+ if attempts == 1 && response.status.between?(500, 599)
322
+ if retry_after > MAX_RETRY_WAIT_SECONDS
323
+ raise FetchError, "GitHub API returned HTTP #{response.status}; retry after #{retry_after} seconds"
324
+ end
325
+ @sleeper.call(retry_after) if retry_after.positive?
326
+ next
327
+ end
328
+ raise FetchError, "GitHub API returned HTTP #{response.status}"
329
+ end
330
+ end
331
+ end
332
+
333
+ module_function
334
+
335
+ def materialize(references, transport: nil, cache_root: nil, manifest: nil)
336
+ normalized_references = normalize_references(references)
337
+ return materialize_manifest(normalized_references, manifest) unless manifest.nil?
338
+ return [].freeze if normalized_references.empty?
339
+
340
+ raise Invalid, "GitHub Markdown transport is required" unless transport
341
+ raise Invalid, "GitHub Markdown cache root is required" unless cache_root
342
+
343
+ cache = Cache.new(cache_root)
344
+ resolved = {}
345
+ content = {}
346
+ total_bytes = 0
347
+ normalized_references.map do |reference|
348
+ commit = if reference.ref.match?(COMMIT)
349
+ reference.ref
350
+ else
351
+ resolved.fetch([reference.repository, reference.ref]) do |key|
352
+ resolved[key] = transport.resolve_commit(repository: reference.repository, ref: reference.ref)
353
+ end
354
+ end
355
+ unless commit.is_a?(String) && commit.match?(COMMIT)
356
+ raise FetchError, "GitHub returned an invalid commit for #{reference.repository}@#{reference.ref}"
357
+ end
358
+ commit = commit.downcase
359
+ content_key = [reference.repository, commit, reference.path]
360
+ markdown = content.fetch(content_key) do
361
+ cached = cache.read(repository: reference.repository, commit: commit, path: reference.path)
362
+ if cached
363
+ content[content_key] = normalize_markdown(cached, reference)
364
+ else
365
+ fetched = transport.fetch_markdown(
366
+ repository: reference.repository,
367
+ commit: commit,
368
+ path: reference.path
369
+ )
370
+ normalized = normalize_markdown(fetched, reference)
371
+ content[content_key] = cache.write(
372
+ repository: reference.repository,
373
+ commit: commit,
374
+ path: reference.path,
375
+ markdown: normalized
376
+ )
377
+ end
378
+ end
379
+ total_bytes += markdown.bytesize
380
+ if total_bytes > MAX_TOTAL_BYTES
381
+ raise FetchError, "GitHub Markdown content exceeds the 8 MiB total limit"
382
+ end
383
+ Document.new(
384
+ repository: reference.repository,
385
+ requested_ref: reference.ref,
386
+ path: reference.path,
387
+ resolved_commit: commit,
388
+ source_url: source_url(reference.repository, commit, reference.path),
389
+ digest: Digest::SHA256.hexdigest(markdown.b),
390
+ markdown: markdown
391
+ )
392
+ end.freeze
393
+ end
394
+
395
+ def dump_manifest(documents)
396
+ unless documents.is_a?(Array) && documents.length <= MAX_REFERENCES
397
+ raise ManifestError, "GitHub Markdown manifest documents must be an array with at most #{MAX_REFERENCES} entries"
398
+ end
399
+
400
+ total_bytes = 0
401
+ serialized = documents.map.with_index do |document, index|
402
+ unless document.is_a?(Document)
403
+ raise ManifestError, "GitHub Markdown manifest document #{index} is invalid"
404
+ end
405
+ values = %i[repository requested_ref path resolved_commit digest markdown].to_h do |field|
406
+ [field, document.public_send(field)]
407
+ end
408
+ unless values.values.all? { |value| value.is_a?(String) }
409
+ raise ManifestError, "GitHub Markdown manifest document #{index} fields must be strings"
410
+ end
411
+ raw_document = {
412
+ "repository" => document.repository,
413
+ "requested_ref" => document.requested_ref,
414
+ "path" => document.path,
415
+ "resolved_commit" => document.resolved_commit,
416
+ "digest" => document.digest,
417
+ "markdown" => document.markdown
418
+ }
419
+ begin
420
+ reference = Reference.new(
421
+ repository: normalize_repository(document.repository),
422
+ ref: normalize_ref(document.requested_ref, single_segment: false),
423
+ path: normalize_path(document.path)
424
+ )
425
+ validate_manifest_document(raw_document, reference, index)
426
+ rescue Invalid => exception
427
+ raise ManifestError, "GitHub Markdown manifest document #{index} is invalid: #{exception.message}"
428
+ end
429
+ total_bytes += document.markdown.bytesize
430
+ raise ManifestError, "GitHub Markdown manifest exceeds the 8 MiB total limit" if total_bytes > MAX_TOTAL_BYTES
431
+
432
+ raw_document
433
+ end
434
+ output = JSON.generate("version" => MANIFEST_VERSION, "documents" => serialized)
435
+ if output.bytesize > MAX_MANIFEST_BYTES
436
+ raise ManifestError, "GitHub Markdown manifest exceeds the 64 MiB serialized limit"
437
+ end
438
+ output.freeze
439
+ end
440
+
441
+ def normalize_references(references)
442
+ unless references.is_a?(Array)
443
+ raise Invalid, "GitHub Markdown references must be an array"
444
+ end
445
+ if references.length > MAX_REFERENCES
446
+ raise Invalid, "GitHub Markdown supports at most #{MAX_REFERENCES} references per site"
447
+ end
448
+
449
+ references.map { |reference| normalize(reference) }.freeze
450
+ end
451
+
452
+ def materialize_manifest(references, raw_manifest)
453
+ manifest = parse_manifest(raw_manifest)
454
+ unless manifest.keys.all? { |key| key.is_a?(String) } &&
455
+ manifest.keys.sort == %w[documents version] && manifest["version"] == MANIFEST_VERSION &&
456
+ manifest["documents"].is_a?(Array)
457
+ raise ManifestError, "GitHub Markdown manifest must be a version 1 document list"
458
+ end
459
+ unless manifest["documents"].length == references.length
460
+ raise ManifestError, "GitHub Markdown manifest does not match the configured references"
461
+ end
462
+
463
+ total_bytes = 0
464
+ manifest["documents"].each_with_index.map do |raw_document, index|
465
+ document = validate_manifest_document(raw_document, references.fetch(index), index)
466
+ total_bytes += document.markdown.bytesize
467
+ if total_bytes > MAX_TOTAL_BYTES
468
+ raise ManifestError, "GitHub Markdown manifest exceeds the 8 MiB total limit"
469
+ end
470
+ document
471
+ end.freeze
472
+ end
473
+
474
+ def parse_manifest(raw_manifest)
475
+ case raw_manifest
476
+ when String
477
+ unless raw_manifest.valid_encoding? && raw_manifest.bytesize <= MAX_MANIFEST_BYTES
478
+ raise ManifestError, "GitHub Markdown manifest must be valid UTF-8 and at most 64 MiB"
479
+ end
480
+ parsed = JSON.parse(raw_manifest)
481
+ raise ManifestError, "GitHub Markdown manifest must be a JSON object" unless parsed.is_a?(Hash)
482
+
483
+ parsed
484
+ when Hash
485
+ raw_manifest
486
+ else
487
+ raise ManifestError, "GitHub Markdown manifest must be JSON or a mapping"
488
+ end
489
+ rescue JSON::ParserError
490
+ raise ManifestError, "GitHub Markdown manifest is invalid JSON"
491
+ end
492
+
493
+ def validate_manifest_document(raw_document, reference, index)
494
+ keys = %w[digest markdown path repository requested_ref resolved_commit]
495
+ unless raw_document.is_a?(Hash) && raw_document.keys.all? { |key| key.is_a?(String) } &&
496
+ raw_document.keys.sort == keys
497
+ raise ManifestError, "GitHub Markdown manifest document #{index} has an invalid shape"
498
+ end
499
+ unless keys.all? { |key| raw_document[key].is_a?(String) }
500
+ raise ManifestError, "GitHub Markdown manifest document #{index} fields must be strings"
501
+ end
502
+
503
+ begin
504
+ manifest_reference = Reference.new(
505
+ repository: normalize_repository(raw_document["repository"]),
506
+ ref: normalize_ref(raw_document["requested_ref"], single_segment: false),
507
+ path: normalize_path(raw_document["path"])
508
+ )
509
+ rescue Invalid => exception
510
+ raise ManifestError, "GitHub Markdown manifest document #{index} is invalid: #{exception.message}"
511
+ end
512
+ unless manifest_reference == reference && manifest_reference.repository == raw_document["repository"] &&
513
+ manifest_reference.ref == raw_document["requested_ref"] && manifest_reference.path == raw_document["path"]
514
+ raise ManifestError, "GitHub Markdown manifest document #{index} does not match its configured reference"
515
+ end
516
+
517
+ commit = raw_document["resolved_commit"].dup
518
+ unless commit.match?(COMMIT) && commit == commit.downcase
519
+ raise ManifestError, "GitHub Markdown manifest document #{index} has an invalid commit"
520
+ end
521
+ digest = raw_document["digest"].dup
522
+ unless digest.match?(/\A[0-9a-f]{64}\z/)
523
+ raise ManifestError, "GitHub Markdown manifest document #{index} has an invalid digest"
524
+ end
525
+ begin
526
+ markdown = normalize_markdown(raw_document["markdown"], reference)
527
+ rescue FetchError => exception
528
+ raise ManifestError, "GitHub Markdown manifest document #{index} is invalid: #{exception.message}"
529
+ end
530
+ unless Digest::SHA256.hexdigest(markdown.b) == digest
531
+ raise ManifestError, "GitHub Markdown manifest document #{index} digest does not match its Markdown"
532
+ end
533
+
534
+ Document.new(
535
+ repository: reference.repository,
536
+ requested_ref: reference.ref,
537
+ path: reference.path,
538
+ resolved_commit: commit,
539
+ source_url: source_url(reference.repository, commit, reference.path),
540
+ digest: digest,
541
+ markdown: markdown
542
+ )
543
+ end
544
+
545
+ def normalize(raw)
546
+ if raw.is_a?(Reference)
547
+ return normalize_mapping(
548
+ "repository" => raw.repository,
549
+ "ref" => raw.ref,
550
+ "path" => raw.path
551
+ )
552
+ end
553
+ return normalize_mapping(raw) if raw.is_a?(Hash)
554
+ normalize_url(raw)
555
+ end
556
+
557
+ def normalize_mapping(raw)
558
+ unless raw.keys.all? { |key| key.is_a?(String) }
559
+ raise Invalid, "github_markdown mapping keys must be strings"
560
+ end
561
+ unknown = raw.keys - %w[repository ref path]
562
+ raise Invalid, "unknown github_markdown setting #{unknown.sort.first.inspect}" unless unknown.empty?
563
+ missing = %w[repository ref path].reject { |key| raw.key?(key) }
564
+ raise Invalid, "github_markdown requires #{missing.join(', ')}" unless missing.empty?
565
+
566
+ Reference.new(
567
+ repository: normalize_repository(raw["repository"]),
568
+ ref: normalize_ref(raw["ref"], single_segment: false),
569
+ path: normalize_path(raw["path"])
570
+ )
571
+ end
572
+
573
+ def normalize_url(raw)
574
+ unless raw.is_a?(String) && raw.valid_encoding? && !raw.match?(ENCODED_SEPARATOR)
575
+ raise Invalid, "github_markdown URL shorthand requires a single-segment ref"
576
+ end
577
+ uri = URI.parse(raw)
578
+ parts = uri.path.split("/", -1)
579
+ unless uri.scheme&.casecmp("https")&.zero? && uri.host&.casecmp("github.com")&.zero? &&
580
+ uri.port == 443 && uri.userinfo.nil? && uri.query.nil? && uri.fragment.nil? &&
581
+ parts.shift == "" && parts.length >= 5 && parts[2] == "blob"
582
+ raise Invalid, "github_markdown must be a public github.com blob URL"
583
+ end
584
+
585
+ Reference.new(
586
+ repository: normalize_repository(parts[0, 2].map { |part| URI.decode_uri_component(part) }.join("/")),
587
+ ref: normalize_ref(URI.decode_uri_component(parts[3]), single_segment: true),
588
+ path: normalize_path(parts[4..].map { |part| URI.decode_uri_component(part) }.join("/"))
589
+ )
590
+ rescue URI::InvalidURIError, URI::InvalidComponentError, TypeError
591
+ raise Invalid, "github_markdown must be a public github.com blob URL"
592
+ end
593
+
594
+ def normalize_repository(raw)
595
+ unless raw.is_a?(String) && raw.valid_encoding?
596
+ raise Invalid, "github_markdown.repository must be owner/repository"
597
+ end
598
+ parts = raw.unicode_normalize(:nfc).split("/", -1)
599
+ unless parts.length == 2 && parts.all? { |part| part.match?(REPOSITORY_COMPONENT) }
600
+ raise Invalid, "github_markdown.repository must be owner/repository"
601
+ end
602
+ parts.join("/").downcase
603
+ end
604
+
605
+ def normalize_ref(raw, single_segment:)
606
+ unless raw.is_a?(String) && raw.valid_encoding?
607
+ raise Invalid, "github_markdown.ref must be a safe Git ref"
608
+ end
609
+ ref = raw.unicode_normalize(:nfc)
610
+ components = ref.split("/", -1)
611
+ invalid = ref.empty? || ref.bytesize > 255 || ref.match?(INVALID_REF_CHARACTER) ||
612
+ ref == "@" || ref.include?("..") || ref.include?("@{") ||
613
+ components.any? { |part| part.empty? || part.start_with?(".") || part.end_with?(".", ".lock") } ||
614
+ (single_segment && components.length != 1)
615
+ if invalid
616
+ suffix = single_segment ? " containing a single-segment ref" : " containing a safe Git ref"
617
+ raise Invalid, "github_markdown requires#{suffix}"
618
+ end
619
+ ref
620
+ end
621
+
622
+ def normalize_path(raw)
623
+ unless raw.is_a?(String) && raw.valid_encoding?
624
+ raise Invalid, "github_markdown.path must be a safe relative Markdown path"
625
+ end
626
+ path = raw.unicode_normalize(:nfc)
627
+ parts = path.split("/", -1)
628
+ invalid = path.empty? || path.bytesize > 1024 || path.start_with?("/") || path.include?("\\") ||
629
+ parts.any? { |part| part.empty? || part == "." || part == ".." || part.match?(/[\x00-\x1f\x7f]/) }
630
+ extension = File.extname(path).downcase
631
+ unless !invalid && MARKDOWN_EXTENSIONS.include?(extension)
632
+ raise Invalid, "github_markdown.path must be a safe relative .md or .markdown path"
633
+ end
634
+ path
635
+ end
636
+
637
+ def source_url(repository, commit, path)
638
+ encoded_path = path.split("/").map { |part| URI.encode_uri_component(part) }.join("/")
639
+ "#{repository_url(repository)}/blob/#{commit}/#{encoded_path}"
640
+ end
641
+
642
+ def repository_url(repository)
643
+ "https://github.com/#{normalize_repository(repository)}"
644
+ end
645
+
646
+ def normalize_markdown(raw, reference)
647
+ unless raw.is_a?(String)
648
+ raise FetchError, "GitHub Markdown content is not a byte string for #{reference.repository}/#{reference.path}"
649
+ end
650
+ if raw.bytesize > MAX_FILE_BYTES
651
+ raise FetchError, "GitHub Markdown file exceeds the 1 MiB limit: #{reference.repository}/#{reference.path}"
652
+ end
653
+
654
+ markdown = raw.dup.force_encoding(Encoding::UTF_8)
655
+ unless markdown.valid_encoding?
656
+ raise FetchError, "GitHub Markdown file is not valid UTF-8: #{reference.repository}/#{reference.path}"
657
+ end
658
+ unless OutputText.valid?(markdown)
659
+ raise FetchError, "GitHub Markdown file contains a character forbidden by XML 1.0: #{reference.repository}/#{reference.path}"
660
+ end
661
+ markdown
662
+ end
663
+ end
664
+ end