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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +133 -0
- data/website/_config.yml +21 -0
- data/website/_includes/website/archive-timeline.html +8 -0
- data/website/_includes/website/blog-ledger.html +19 -0
- data/website/_includes/website/blog-navigation.html +7 -0
- data/website/_includes/website/comments.html +22 -0
- data/website/_includes/website/contact-icon.html +14 -0
- data/website/_includes/website/context-tags.html +7 -0
- data/website/_includes/website/context.html +3 -0
- data/website/_includes/website/dialogs.html +71 -0
- data/website/_includes/website/docs-navigation.html +7 -0
- data/website/_includes/website/docs-tree.html +8 -0
- data/website/_includes/website/footer.html +14 -0
- data/website/_includes/website/github-icon.html +3 -0
- data/website/_includes/website/head.html +41 -0
- data/website/_includes/website/header.html +35 -0
- data/website/_includes/website/language-switcher.html +16 -0
- data/website/_includes/website/local-graph.html +25 -0
- data/website/_includes/website/note-header.html +35 -0
- data/website/_includes/website/outline.html +9 -0
- data/website/_includes/website/page-actions.html +34 -0
- data/website/_includes/website/portfolio-grid.html +36 -0
- data/website/_includes/website/portfolio-topics.html +4 -0
- data/website/_includes/website/post-cards.html +16 -0
- data/website/_includes/website/related-articles.html +7 -0
- data/website/_includes/website/relations.html +16 -0
- data/website/_includes/website/source-actions.html +28 -0
- data/website/_includes/website/system-page.html +31 -0
- data/website/_includes/website/tab-collection.html +28 -0
- data/website/_includes/website/tag-filter.html +12 -0
- data/website/_layouts/website-docs.html +20 -0
- data/website/_layouts/website-minimal.html +93 -0
- data/website/_layouts/website-redirect.html +17 -0
- data/website/assets/chunks/abnfDiagram-N423BO3Z-TSORAEI2.js +1 -0
- data/website/assets/chunks/architecture-TIHT7OUA-OUC5PEC7.js +1 -0
- data/website/assets/chunks/architectureDiagram-T3A2C74G-6P5MB2DJ.js +36 -0
- data/website/assets/chunks/blockDiagram-VBNYF7ZC-ZWINYROZ.js +132 -0
- data/website/assets/chunks/c4Diagram-5PPSVZJV-R5RMEAOT.js +10 -0
- data/website/assets/chunks/chunk-22DRK7VS.js +1 -0
- data/website/assets/chunks/chunk-25REBZ7M.js +2 -0
- data/website/assets/chunks/chunk-2RQCUDTG.js +1 -0
- data/website/assets/chunks/chunk-34FHPXU4.js +1 -0
- data/website/assets/chunks/chunk-57XXJOQN.js +10 -0
- data/website/assets/chunks/chunk-6DCNDVBX.js +2 -0
- data/website/assets/chunks/chunk-6DHFEWGX.js +1 -0
- data/website/assets/chunks/chunk-6VQ47JJS.js +2 -0
- data/website/assets/chunks/chunk-7CSIRG7W.js +127 -0
- data/website/assets/chunks/chunk-A43YELH5.js +1 -0
- data/website/assets/chunks/chunk-AIX6IH4O.js +1 -0
- data/website/assets/chunks/chunk-B3K7RH5O.js +88 -0
- data/website/assets/chunks/chunk-CBGUD2NP.js +1 -0
- data/website/assets/chunks/chunk-D3WOCELE.js +1 -0
- data/website/assets/chunks/chunk-D6FURU6X.js +1 -0
- data/website/assets/chunks/chunk-DGEGT77F.js +1 -0
- data/website/assets/chunks/chunk-DN52R6U7.js +1 -0
- data/website/assets/chunks/chunk-E3T3IFAC.js +1 -0
- data/website/assets/chunks/chunk-EIBFAW37.js +1 -0
- data/website/assets/chunks/chunk-ERWOH47R.js +1 -0
- data/website/assets/chunks/chunk-ETOVTYWF.js +1 -0
- data/website/assets/chunks/chunk-GHD3PFWA.js +231 -0
- data/website/assets/chunks/chunk-GMBKQ7NZ.js +1 -0
- data/website/assets/chunks/chunk-GQM25EPP.js +156 -0
- data/website/assets/chunks/chunk-GSOU4UTT.js +1 -0
- data/website/assets/chunks/chunk-HEMELP7J.js +1 -0
- data/website/assets/chunks/chunk-HZ5I5NJO.js +1 -0
- data/website/assets/chunks/chunk-IP56FOSI.js +70 -0
- data/website/assets/chunks/chunk-L7BMR7WL.js +1 -0
- data/website/assets/chunks/chunk-LGUG3SQB.js +1 -0
- data/website/assets/chunks/chunk-LOMHZYS2.js +1 -0
- data/website/assets/chunks/chunk-MII4VZEB.js +3 -0
- data/website/assets/chunks/chunk-MYMJLHQL.js +62 -0
- data/website/assets/chunks/chunk-NBH7YOBD.js +15 -0
- data/website/assets/chunks/chunk-NCOVM3YF.js +1 -0
- data/website/assets/chunks/chunk-OAGTSILX.js +1 -0
- data/website/assets/chunks/chunk-OCX5OCIU.js +321 -0
- data/website/assets/chunks/chunk-OE3NOVIH.js +1 -0
- data/website/assets/chunks/chunk-PLP5XUTJ.js +1 -0
- data/website/assets/chunks/chunk-PNOERP6G.js +1 -0
- data/website/assets/chunks/chunk-QBPQ7DPE.js +1 -0
- data/website/assets/chunks/chunk-RWFFDL53.js +1 -0
- data/website/assets/chunks/chunk-SEGIW3MP.js +1 -0
- data/website/assets/chunks/chunk-SZDMZNUS.js +1 -0
- data/website/assets/chunks/chunk-T2QBAPVT.js +1 -0
- data/website/assets/chunks/chunk-UE533OZ7.js +1 -0
- data/website/assets/chunks/chunk-UTWOJE2A.js +1 -0
- data/website/assets/chunks/chunk-VOCDYA3H.js +1 -0
- data/website/assets/chunks/chunk-VYZZPKGK.js +161 -0
- data/website/assets/chunks/chunk-WWLZIRS6.js +1 -0
- data/website/assets/chunks/chunk-XNXYDBKX.js +32 -0
- data/website/assets/chunks/chunk-XQUIO562.js +1 -0
- data/website/assets/chunks/chunk-Y2XVFW7T.js +2 -0
- data/website/assets/chunks/chunk-YORIST7Z.js +1 -0
- data/website/assets/chunks/chunk-Z2WBQPVY.js +206 -0
- data/website/assets/chunks/classDiagram-JCYQIIEL-OFDVDDIM.js +1 -0
- data/website/assets/chunks/classDiagram-v2-OCEON4UE-E36B6BGQ.js +1 -0
- data/website/assets/chunks/cose-bilkent-JH36ORCC-CBAW64AB.js +1 -0
- data/website/assets/chunks/cynefin-VYW2F7L2-CBO6DLNV.js +1 -0
- data/website/assets/chunks/cynefinDiagram-MW4NZA55-DFINLOUY.js +62 -0
- data/website/assets/chunks/dagre-VZM6K2ZE-BZFBSZAH.js +4 -0
- data/website/assets/chunks/diagram-7IWD3JNH-XQHTVOU2.js +30 -0
- data/website/assets/chunks/diagram-B4RE2ZJO-FTSXBZJR.js +3 -0
- data/website/assets/chunks/diagram-LBJQPF4R-ZSZW4OLH.js +24 -0
- data/website/assets/chunks/diagram-Q27KOJAE-2IV5TVCJ.js +24 -0
- data/website/assets/chunks/diagram-UB23O5K3-D6PDUTFL.js +41 -0
- data/website/assets/chunks/ebnfDiagram-BXEA7PRR-C6MAER3M.js +1 -0
- data/website/assets/chunks/erDiagram-JOGREHBK-EDAD2H5M.js +85 -0
- data/website/assets/chunks/eventmodeling-45OFAUF4-4K577DMF.js +1 -0
- data/website/assets/chunks/flowDiagram-UKHOOZJN-545F4YX3.js +1 -0
- data/website/assets/chunks/ganttDiagram-PKOTCBZU-7A7EKMY3.js +292 -0
- data/website/assets/chunks/gitGraph-TEB2WS4Q-3WUB7WYQ.js +1 -0
- data/website/assets/chunks/gitGraphDiagram-DS77QQ5N-A5UWV5FF.js +106 -0
- data/website/assets/chunks/info-DKCQHKI2-EOOHI7XS.js +1 -0
- data/website/assets/chunks/infoDiagram-6WML65LV-CWIOOWJP.js +2 -0
- data/website/assets/chunks/ishikawaDiagram-WSZJBQD7-CEASQVDL.js +70 -0
- data/website/assets/chunks/journeyDiagram-NVQOT4AX-LYG3S2AW.js +139 -0
- data/website/assets/chunks/kanban-definition-27J2QSJJ-HCC6KAYI.js +89 -0
- data/website/assets/chunks/katex-F4MQ6FB3.js +257 -0
- data/website/assets/chunks/mindmap-definition-FAOFIHXS-JMGSM3RE.js +96 -0
- data/website/assets/chunks/packet-7NZHBO7P-W3SJA3MY.js +1 -0
- data/website/assets/chunks/pegDiagram-VL7TDLO6-4JO3Y3Z3.js +1 -0
- data/website/assets/chunks/pie-RZYD4A2V-YOIQBTY7.js +1 -0
- data/website/assets/chunks/pieDiagram-7S7Q4E2Y-VFQ7SE46.js +39 -0
- data/website/assets/chunks/quadrantDiagram-CIZ2JOQS-JISDI2S6.js +7 -0
- data/website/assets/chunks/radar-I7S5WNFK-NMKP5AZO.js +1 -0
- data/website/assets/chunks/railroad-3IZDKUUU-7MOIV7CI.js +1 -0
- data/website/assets/chunks/railroad-abnf-AHOZXSZD-HXNH5SLO.js +1 -0
- data/website/assets/chunks/railroad-ebnf-EBAXGLYW-OWDJEWMK.js +1 -0
- data/website/assets/chunks/railroad-peg-LSFZ7HO6-ASSINGP5.js +1 -0
- data/website/assets/chunks/railroadDiagram-AXF67PYL-AFV5VP5F.js +1 -0
- data/website/assets/chunks/requirementDiagram-LRYGKXZP-RNRPEHHN.js +84 -0
- data/website/assets/chunks/sankeyDiagram-W5VNT64P-O54R2L3E.js +40 -0
- data/website/assets/chunks/sequenceDiagram-SI44F4Z6-42WISKJ7.js +162 -0
- data/website/assets/chunks/sizeCapture-X5ZJPWSS-LRN2Q37T.js +1 -0
- data/website/assets/chunks/stateDiagram-OKZ733FA-BWIBJXIR.js +1 -0
- data/website/assets/chunks/stateDiagram-v2-UEYNNEHI-L73LHL3L.js +1 -0
- data/website/assets/chunks/swimlanes-SLNWSIFB-ECOAE7LQ.js +1 -0
- data/website/assets/chunks/swimlanesDiagram-ULZ7WXOC-3CKKKFZZ.js +8 -0
- data/website/assets/chunks/timeline-definition-Z64GVDOM-HR2OL5G7.js +120 -0
- data/website/assets/chunks/treeView-QDETBFTQ-C6D5WFJA.js +1 -0
- data/website/assets/chunks/treemap-6X3UGDF4-JFCDMSCA.js +1 -0
- data/website/assets/chunks/vennDiagram-T6HMQDX7-62WAAPTF.js +34 -0
- data/website/assets/chunks/wardley-OPB4EBWU-RQIOII7I.js +1 -0
- data/website/assets/chunks/wardleyDiagram-T6FBY63Y-KUXO3NHV.js +78 -0
- data/website/assets/chunks/xychartDiagram-ELKLHX3M-I54W4HJ7.js +7 -0
- data/website/assets/color-scheme-bootstrap-4H4ZR4BW.js +1 -0
- data/website/assets/docs-3WBBQT6K.css +1 -0
- data/website/assets/docs-W3D5DWQF.js +1 -0
- data/website/assets/docs-navigation-75ALFYBA.js +1 -0
- data/website/assets/graph-QWL54LWD.js +1 -0
- data/website/assets/manifest.json +314 -0
- data/website/assets/math-7JTH4OWZ.js +21 -0
- data/website/assets/mermaid-RXC6URG4.js +10 -0
- data/website/assets/minimal-3NPTDWQX.js +1 -0
- data/website/assets/minimal-E2VATXXS.css +1 -0
- data/website/assets/previews-FMHOX7UX.js +1 -0
- data/website/assets/search-BH73JZDU.js +1 -0
- data/website/assets/search-worker-SM4IM4J7.js +1 -0
- data/website/exe/jekyll-obsidian +5 -0
- data/website/lib/jekyll_obsidian/adapter.rb +733 -0
- data/website/lib/jekyll_obsidian/built_in_themes.rb +855 -0
- data/website/lib/jekyll_obsidian/cli.rb +84 -0
- data/website/lib/jekyll_obsidian/content_policy.rb +194 -0
- data/website/lib/jekyll_obsidian/destination_registry.rb +39 -0
- data/website/lib/jekyll_obsidian/directory_indexes.rb +30 -0
- data/website/lib/jekyll_obsidian/external_media.rb +371 -0
- data/website/lib/jekyll_obsidian/front_matter.rb +453 -0
- data/website/lib/jekyll_obsidian/github_markdown.rb +664 -0
- data/website/lib/jekyll_obsidian/html_publication.rb +206 -0
- data/website/lib/jekyll_obsidian/initializer.rb +68 -0
- data/website/lib/jekyll_obsidian/localized_compiler.rb +1044 -0
- data/website/lib/jekyll_obsidian/media_policy.rb +41 -0
- data/website/lib/jekyll_obsidian/ofm_scanner.rb +493 -0
- data/website/lib/jekyll_obsidian/output_text.rb +13 -0
- data/website/lib/jekyll_obsidian/preview.rb +93 -0
- data/website/lib/jekyll_obsidian/published_markdown.rb +32 -0
- data/website/lib/jekyll_obsidian/raw_html_manifest.rb +66 -0
- data/website/lib/jekyll_obsidian/runtime.rb +197 -0
- data/website/lib/jekyll_obsidian/site_compilation.rb +147 -0
- data/website/lib/jekyll_obsidian/site_navigation.rb +517 -0
- data/website/lib/jekyll_obsidian/url_builder.rb +163 -0
- data/website/lib/jekyll_obsidian/value_objects.rb +198 -0
- data/website/lib/jekyll_obsidian/vault_compiler.rb +2771 -0
- data/website/lib/jekyll_obsidian/version.rb +6 -0
- data/website/lib/jekyll_obsidian/workspace_layout.rb +104 -0
- data/website/lib/jekyll_obsidian.rb +22 -0
- data/website/scripts/audit-site.rb +120 -0
- data/website/scripts/templates/pages.yml +55 -0
- data/website/scripts/verify-site-urls.rb +585 -0
- 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
|