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,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
|