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,371 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "nokogiri"
5
+
6
+ require_relative "media_policy"
7
+ require_relative "value_objects"
8
+
9
+ module JekyllObsidian
10
+ # Closed, deterministic policy for author-selected remote media. Provider
11
+ # URLs are parsed and rebuilt instead of being copied into active markup.
12
+ # No network lookup is performed during compilation.
13
+ module ExternalMedia
14
+ Descriptor = ImmutableRecord.define(
15
+ :kind,
16
+ :provider,
17
+ :source_url,
18
+ :fallback_url,
19
+ :origin,
20
+ :media_type,
21
+ :iframe_allow,
22
+ :iframe_sandbox,
23
+ :title,
24
+ :width,
25
+ :height,
26
+ :identifier
27
+ )
28
+
29
+ class Invalid < ArgumentError; end
30
+
31
+ YOUTUBE_HOSTS = %w[
32
+ youtube.com
33
+ www.youtube.com
34
+ m.youtube.com
35
+ music.youtube.com
36
+ www.youtube-nocookie.com
37
+ youtu.be
38
+ ].freeze
39
+ BILIBILI_HOSTS = %w[bilibili.com www.bilibili.com m.bilibili.com player.bilibili.com].freeze
40
+ VIMEO_HOSTS = %w[vimeo.com www.vimeo.com player.vimeo.com].freeze
41
+ SHORTENER_HOSTS = %w[b23.tv www.b23.tv].freeze
42
+ X_HOSTS = %w[x.com www.x.com twitter.com www.twitter.com mobile.twitter.com].freeze
43
+ PROVIDER_HOSTS = (YOUTUBE_HOSTS + BILIBILI_HOSTS + VIMEO_HOSTS + SHORTENER_HOSTS).freeze
44
+
45
+ YOUTUBE_ID = /\A[A-Za-z0-9_-]{11}\z/
46
+ BILIBILI_BVID = /\ABV1[A-Za-z0-9]{9}\z/
47
+ POSITIVE_INTEGER = /\A[1-9]\d*\z/
48
+ NON_NEGATIVE_INTEGER = /\A(?:0|[1-9]\d*)\z/
49
+ VIMEO_HASH = /\A[0-9A-Fa-f]{10}\z/
50
+ CONTROL_OR_BACKSLASH = /[\x00-\x20\x7f\\]/
51
+ ENCODED_SEPARATOR = /%(?:2f|5c)/i
52
+ X_HANDLE = /\A[A-Za-z0-9_]{1,15}\z/
53
+ X_STATUS_ID = /\A[1-9]\d*\z/
54
+ IFRAME_SANDBOX = "allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation"
55
+
56
+ module_function
57
+
58
+ # Resolves Markdown image syntax. Ordinary HTTPS images are represented as
59
+ # :image so the compiler can apply Obsidian-compatible dimensions and its
60
+ # normal lazy-loading attributes.
61
+ def resolve(raw_url)
62
+ uri = parse(raw_url)
63
+ return nil unless uri&.scheme&.casecmp("https")&.zero?
64
+
65
+ validate_https!(uri, allow_fragment: true)
66
+
67
+ host = uri.host.to_s.downcase
68
+ if X_HOSTS.include?(host) && (tweet = x_tweet(uri))
69
+ return tweet
70
+ end
71
+ if PROVIDER_HOSTS.include?(host)
72
+ validate_https!(uri, provider: true)
73
+ raise Invalid, "shortened provider URLs are not supported; use the expanded video URL" if SHORTENER_HOSTS.include?(host)
74
+
75
+ return youtube(uri) if YOUTUBE_HOSTS.include?(host)
76
+ return bilibili(uri) if BILIBILI_HOSTS.include?(host)
77
+ return vimeo(uri) if VIMEO_HOSTS.include?(host)
78
+ end
79
+
80
+ if MediaPolicy.kind(uri.path.to_s) == :video
81
+ validate_https!(uri)
82
+ return Descriptor.new(
83
+ kind: :direct_video,
84
+ source_url: uri.to_s,
85
+ fallback_url: uri.to_s,
86
+ origin: origin(uri),
87
+ media_type: MediaPolicy.media_type(uri.path)
88
+ )
89
+ end
90
+
91
+ Descriptor.new(kind: :image, source_url: uri.to_s, fallback_url: uri.to_s, origin: origin(uri))
92
+ rescue URI::InvalidURIError
93
+ raise Invalid, "external media URL is invalid"
94
+ end
95
+
96
+ # Resolves raw iframe embeds into compiler-owned markup. Known players are
97
+ # canonicalized through their provider policy; other HTTPS pages receive a
98
+ # fixed sandbox and never retain authored active attributes.
99
+ def resolve_frame(raw_url, title: nil, width: nil, height: nil)
100
+ descriptor = resolve(raw_url)
101
+ if descriptor&.kind == :player
102
+ return Descriptor.new(**descriptor.to_h.merge(
103
+ title: safe_title(title),
104
+ width: dimension(width),
105
+ height: dimension(height)
106
+ ))
107
+ end
108
+
109
+ uri = parse(raw_url)
110
+ validate_https!(uri, allow_fragment: true)
111
+ canonical = canonical_https_url(uri)
112
+ Descriptor.new(
113
+ kind: :web_frame,
114
+ provider: :web,
115
+ source_url: canonical,
116
+ fallback_url: canonical,
117
+ origin: origin(uri),
118
+ iframe_sandbox: IFRAME_SANDBOX,
119
+ title: safe_title(title) || "Embedded page from #{uri.host.downcase}",
120
+ width: dimension(width),
121
+ height: dimension(height) || 480
122
+ )
123
+ rescue URI::InvalidURIError
124
+ raise Invalid, "iframe URL is invalid"
125
+ end
126
+
127
+ def resolve_iframe(raw_html, closed: true)
128
+ raise Invalid, "iframe element is missing its closing tag" unless closed
129
+
130
+ fragment = Nokogiri::HTML5.fragment(raw_html.to_s)
131
+ elements = fragment.element_children
132
+ frame = elements.first
133
+ unless elements.length == 1 && frame&.name == "iframe" && fragment.css("iframe").length == 1
134
+ raise Invalid, "iframe embed must contain exactly one iframe element"
135
+ end
136
+ source = frame["src"].to_s.strip
137
+ raise Invalid, "iframe embed requires src" if source.empty?
138
+
139
+ resolve_frame(
140
+ source,
141
+ title: frame["title"],
142
+ width: frame["width"],
143
+ height: frame["height"]
144
+ )
145
+ rescue Nokogiri::XML::SyntaxError
146
+ raise Invalid, "iframe element is invalid"
147
+ end
148
+
149
+ def https_origin(raw_url)
150
+ uri = parse(raw_url)
151
+ return nil unless uri&.scheme&.casecmp("https")&.zero? && uri.host && uri.userinfo.nil?
152
+
153
+ origin(uri)
154
+ rescue URI::InvalidURIError
155
+ nil
156
+ end
157
+
158
+ def youtube(uri)
159
+ pairs = query_pairs(uri)
160
+ host = uri.host.downcase
161
+ identifier = if host == "youtu.be"
162
+ single_path_segment(uri.path)
163
+ elsif uri.path == "/watch"
164
+ single_query_value(pairs, "v")
165
+ elsif (match = uri.path.match(%r{\A/(?:shorts|live|embed)/([^/]+)/?\z}))
166
+ match[1]
167
+ end
168
+ raise Invalid, "YouTube URL must identify one video" unless identifier&.match?(YOUTUBE_ID)
169
+
170
+ start = youtube_start(pairs)
171
+ query = start ? "?start=#{start}" : ""
172
+ Descriptor.new(
173
+ kind: :player,
174
+ provider: :youtube,
175
+ source_url: "https://www.youtube-nocookie.com/embed/#{identifier}#{query}",
176
+ fallback_url: "https://www.youtube.com/watch?v=#{identifier}",
177
+ origin: "https://www.youtube-nocookie.com",
178
+ iframe_allow: "autoplay; encrypted-media; picture-in-picture"
179
+ )
180
+ end
181
+
182
+ def bilibili(uri)
183
+ pairs = query_pairs(uri)
184
+ identifier = nil
185
+ key = nil
186
+ if uri.host.downcase == "player.bilibili.com" && uri.path == "/player.html"
187
+ candidates = {
188
+ "bvid" => single_query_value(pairs, "bvid"),
189
+ "aid" => single_query_value(pairs, "aid"),
190
+ "episodeId" => single_query_value(pairs, "episodeId")
191
+ }.compact
192
+ raise Invalid, "Bilibili player URL must contain exactly one video identifier" unless candidates.length == 1
193
+
194
+ key, identifier = candidates.first
195
+ elsif (match = uri.path.match(%r{\A/video/(BV1[A-Za-z0-9]{9}|av[1-9]\d*)/?\z}))
196
+ raw = match[1]
197
+ key, identifier = raw.start_with?("BV") ? ["bvid", raw] : ["aid", raw.delete_prefix("av")]
198
+ elsif (match = uri.path.match(%r{\A/bangumi/play/ep([1-9]\d*)/?\z}))
199
+ key, identifier = ["episodeId", match[1]]
200
+ end
201
+
202
+ valid = case key
203
+ when "bvid" then identifier&.match?(BILIBILI_BVID)
204
+ when "aid", "episodeId" then identifier&.match?(POSITIVE_INTEGER)
205
+ end
206
+ raise Invalid, "Bilibili URL must identify one video or episode" unless valid
207
+
208
+ output = [[key, identifier]]
209
+ page = single_query_value(pairs, "p")
210
+ time = single_query_value(pairs, "t")
211
+ raise Invalid, "Bilibili page must be a positive integer" if page && !page.match?(POSITIVE_INTEGER)
212
+ raise Invalid, "Bilibili start time must be a non-negative integer" if time && !time.match?(NON_NEGATIVE_INTEGER)
213
+ output << ["p", page] if page
214
+ output << ["t", time] if time
215
+ fallback = key == "episodeId" ? "https://www.bilibili.com/bangumi/play/ep#{identifier}" :
216
+ "https://www.bilibili.com/video/#{key == "bvid" ? identifier : "av#{identifier}"}"
217
+
218
+ Descriptor.new(
219
+ kind: :player,
220
+ provider: :bilibili,
221
+ source_url: "https://player.bilibili.com/player.html?#{URI.encode_www_form(output)}",
222
+ fallback_url: fallback,
223
+ origin: "https://player.bilibili.com"
224
+ )
225
+ end
226
+
227
+ def vimeo(uri)
228
+ path = uri.path
229
+ identifier = nil
230
+ privacy_hash = nil
231
+ patterns = [
232
+ %r{\A/([1-9]\d*)/?\z},
233
+ %r{\A/album/[1-9]\d*/video/([1-9]\d*)/?\z},
234
+ %r{\A/channels/[^/]+/([1-9]\d*)/?\z},
235
+ %r{\A/groups/[^/]+/videos/([1-9]\d*)/?\z},
236
+ %r{\A/ondemand/[^/]+/([1-9]\d*)/?\z},
237
+ %r{\A/video/([1-9]\d*)/?\z}
238
+ ]
239
+ patterns.each do |pattern|
240
+ match = path.match(pattern)
241
+ if match
242
+ identifier = match[1]
243
+ break
244
+ end
245
+ end
246
+ if (match = path.match(%r{\A/([1-9]\d*)/([0-9A-Fa-f]{10})/?\z}))
247
+ identifier = match[1]
248
+ privacy_hash = match[2]
249
+ end
250
+ pairs = query_pairs(uri)
251
+ query_hash = single_query_value(pairs, "h")
252
+ privacy_hash ||= query_hash
253
+ raise Invalid, "Vimeo URL must identify one numeric video" unless identifier&.match?(POSITIVE_INTEGER)
254
+ raise Invalid, "Vimeo privacy hash is invalid" if privacy_hash && !privacy_hash.match?(VIMEO_HASH)
255
+
256
+ output = []
257
+ output << ["h", privacy_hash] if privacy_hash
258
+ output << ["dnt", "1"]
259
+ Descriptor.new(
260
+ kind: :player,
261
+ provider: :vimeo,
262
+ source_url: "https://player.vimeo.com/video/#{identifier}?#{URI.encode_www_form(output)}",
263
+ fallback_url: "https://vimeo.com/#{identifier}",
264
+ origin: "https://player.vimeo.com",
265
+ iframe_allow: "autoplay; fullscreen; picture-in-picture; clipboard-write"
266
+ )
267
+ end
268
+
269
+ def parse(raw_url)
270
+ text = raw_url.to_s.strip
271
+ raise Invalid, "external media URL contains unsafe characters" if text.empty? || text.match?(CONTROL_OR_BACKSLASH)
272
+
273
+ URI.parse(text)
274
+ end
275
+
276
+ def validate_https!(uri, provider: false, allow_fragment: false)
277
+ unless uri&.scheme&.casecmp("https")&.zero? && uri.host && uri.userinfo.nil? && uri.port == 443
278
+ raise Invalid, "external embeds require an HTTPS URL without credentials or a custom port"
279
+ end
280
+ raise Invalid, "external embed URLs cannot contain a fragment" if uri.fragment && !allow_fragment
281
+ if provider && (uri.path.to_s.match?(ENCODED_SEPARATOR) || uri.path.to_s.split("/").include?(".."))
282
+ raise Invalid, "provider URL path is not allowed"
283
+ end
284
+ end
285
+
286
+ def origin(uri)
287
+ port = uri.port == 443 ? "" : ":#{uri.port}"
288
+ "https://#{uri.host.downcase}#{port}"
289
+ end
290
+
291
+ def canonical_https_url(uri)
292
+ URI::HTTPS.build(
293
+ host: uri.host.downcase,
294
+ path: uri.path.to_s.empty? ? "/" : uri.path,
295
+ query: uri.query,
296
+ fragment: uri.fragment
297
+ ).to_s
298
+ rescue URI::InvalidComponentError
299
+ raise Invalid, "iframe URL is invalid"
300
+ end
301
+
302
+ def x_tweet(uri)
303
+ validate_https!(uri, provider: true, allow_fragment: true)
304
+ match = uri.path.to_s.match(%r{\A/([^/]+)/status/([^/]+)/?\z})
305
+ system_match = uri.path.to_s.match(%r{\A/i/web/status/([^/]+)/?\z}) unless match
306
+ return nil unless match || system_match
307
+
308
+ handle = match&.[](1)
309
+ identifier = match ? match[2] : system_match[1]
310
+ unless identifier.match?(X_STATUS_ID) && (!handle || handle.match?(X_HANDLE))
311
+ raise Invalid, "X URL must contain a valid account and post ID"
312
+ end
313
+
314
+ canonical = handle ? "https://x.com/#{handle}/status/#{identifier}" : "https://x.com/i/web/status/#{identifier}"
315
+ Descriptor.new(
316
+ kind: :tweet,
317
+ provider: :x,
318
+ source_url: canonical,
319
+ fallback_url: canonical,
320
+ origin: "https://platform.twitter.com",
321
+ identifier: identifier,
322
+ title: handle ? "Post by @#{handle} on X" : "Post on X"
323
+ )
324
+ end
325
+
326
+ def dimension(value)
327
+ text = value.to_s.strip
328
+ return nil if text.empty? || !text.match?(POSITIVE_INTEGER)
329
+
330
+ number = text.to_i
331
+ number if number.between?(64, 4096)
332
+ end
333
+
334
+ def safe_title(value)
335
+ title = value.to_s.strip
336
+ return nil if title.empty? || !title.valid_encoding? || title.length > 240
337
+ return nil if title.match?(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/)
338
+
339
+ title
340
+ end
341
+
342
+ def query_pairs(uri)
343
+ URI.decode_www_form(uri.query.to_s)
344
+ rescue ArgumentError
345
+ raise Invalid, "external media query is invalid"
346
+ end
347
+
348
+ def single_query_value(pairs, name)
349
+ values = pairs.filter_map { |key, value| value if key == name }
350
+ raise Invalid, "external media query parameter #{name} must occur at most once" if values.length > 1
351
+
352
+ values.first
353
+ end
354
+
355
+ def single_path_segment(path)
356
+ match = path.match(%r{\A/([^/]+)/?\z})
357
+ match && match[1]
358
+ end
359
+
360
+ def youtube_start(pairs)
361
+ raw = single_query_value(pairs, "start") || single_query_value(pairs, "t")
362
+ return nil if raw.nil?
363
+ return raw.to_i if raw.match?(NON_NEGATIVE_INTEGER)
364
+
365
+ match = raw.match(/\A(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?\z/)
366
+ raise Invalid, "YouTube start time is invalid" unless match && match.captures.compact.any?
367
+
368
+ match[1].to_i * 3600 + match[2].to_i * 60 + match[3].to_i
369
+ end
370
+ end
371
+ end