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,585 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
require "nokogiri"
|
|
6
|
+
require "pathname"
|
|
7
|
+
require "set"
|
|
8
|
+
require "uri"
|
|
9
|
+
|
|
10
|
+
require_relative "../lib/jekyll_obsidian/external_media"
|
|
11
|
+
require_relative "../lib/jekyll_obsidian/raw_html_manifest"
|
|
12
|
+
|
|
13
|
+
class SiteUrlVerifier
|
|
14
|
+
URL_ATTRIBUTES = [
|
|
15
|
+
%w[a href],
|
|
16
|
+
%w[link href],
|
|
17
|
+
%w[script src],
|
|
18
|
+
%w[iframe src],
|
|
19
|
+
%w[img src],
|
|
20
|
+
%w[source src],
|
|
21
|
+
%w[video src],
|
|
22
|
+
%w[video poster],
|
|
23
|
+
%w[audio src],
|
|
24
|
+
%w[object data]
|
|
25
|
+
].freeze
|
|
26
|
+
CSP_DIRECTIVES = {
|
|
27
|
+
"default-src" => ["'self'"],
|
|
28
|
+
"base-uri" => ["'self'"],
|
|
29
|
+
"form-action" => ["'self'"],
|
|
30
|
+
"script-src" => ["'self'"],
|
|
31
|
+
"style-src" => ["'self'", "'unsafe-inline'"],
|
|
32
|
+
"img-src" => ["'self'", "https:"],
|
|
33
|
+
"media-src" => ["'self'"],
|
|
34
|
+
"object-src" => ["'self'"],
|
|
35
|
+
"font-src" => ["'self'"],
|
|
36
|
+
"connect-src" => ["'self'"],
|
|
37
|
+
"frame-src" => ["'self'"]
|
|
38
|
+
}.freeze
|
|
39
|
+
COMMENTS_CSP_DIRECTIVES = CSP_DIRECTIVES.merge(
|
|
40
|
+
"script-src" => ["'self'", "https://giscus.app"],
|
|
41
|
+
"style-src" => ["'self'", "'unsafe-inline'", "https://giscus.app"],
|
|
42
|
+
"frame-src" => ["'self'", "https://giscus.app"]
|
|
43
|
+
).freeze
|
|
44
|
+
GISCUS_ORIGIN = "https://giscus.app"
|
|
45
|
+
X_WIDGET_ORIGIN = "https://platform.twitter.com"
|
|
46
|
+
ANALYTICS_CSP = {
|
|
47
|
+
"cloudflare" => {
|
|
48
|
+
"script-src" => ["https://static.cloudflareinsights.com"],
|
|
49
|
+
"connect-src" => ["https://cloudflareinsights.com"]
|
|
50
|
+
},
|
|
51
|
+
"google" => {
|
|
52
|
+
"script-src" => ["https://www.googletagmanager.com"],
|
|
53
|
+
"connect-src" => [
|
|
54
|
+
"https://*.analytics.google.com",
|
|
55
|
+
"https://*.google-analytics.com",
|
|
56
|
+
"https://www.googletagmanager.com"
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
}.freeze
|
|
60
|
+
|
|
61
|
+
def initialize(site_dir, origin, baseurl)
|
|
62
|
+
@site_dir = File.realpath(site_dir)
|
|
63
|
+
@origin = origin.to_s.delete_suffix("/")
|
|
64
|
+
@baseurl = normalize_baseurl(baseurl)
|
|
65
|
+
@errors = []
|
|
66
|
+
@html_ids = {}
|
|
67
|
+
@raw_html_documents = Set.new
|
|
68
|
+
@raw_html_files = Set.new
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def verify
|
|
72
|
+
load_raw_html_manifest
|
|
73
|
+
html_files = Dir.glob(File.join(@site_dir, "**", "*.html")).sort
|
|
74
|
+
add_error("site contains no HTML") if html_files.empty?
|
|
75
|
+
html_files.each { |path| verify_html(path) }
|
|
76
|
+
verify_json_indexes
|
|
77
|
+
verify_xml_urls
|
|
78
|
+
verify_css_assets
|
|
79
|
+
|
|
80
|
+
unless @errors.empty?
|
|
81
|
+
warn @errors.map { |message| "site URL verification: #{message}" }.join("\n")
|
|
82
|
+
return false
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
puts "site URL verification: ok (#{html_files.length} HTML pages, baseurl #{@baseurl.inspect})"
|
|
86
|
+
true
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def normalize_baseurl(value)
|
|
92
|
+
text = value.to_s
|
|
93
|
+
return "" if text.empty? || text == "/"
|
|
94
|
+
|
|
95
|
+
text.start_with?("/") ? text.delete_suffix("/") : "/#{text.delete_suffix("/")}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def verify_html(path)
|
|
99
|
+
relative = relative_path(path)
|
|
100
|
+
route = route_for_output(relative)
|
|
101
|
+
raw_html = @raw_html_documents.include?(relative)
|
|
102
|
+
document = Nokogiri::HTML5.parse(File.read(path, encoding: "UTF-8"))
|
|
103
|
+
@html_ids[path] = document.css("[id]").map { |node| node["id"] }.to_set
|
|
104
|
+
unless raw_html
|
|
105
|
+
csp_node = document.css("meta[http-equiv]").find do |node|
|
|
106
|
+
node["http-equiv"].to_s.casecmp("Content-Security-Policy").zero?
|
|
107
|
+
end
|
|
108
|
+
csp = csp_node&.[]("content").to_s
|
|
109
|
+
add_error("#{relative}: missing production meta CSP") if csp.empty?
|
|
110
|
+
comments = !document.at_css("[data-website-comments-load]").nil?
|
|
111
|
+
analytics = verify_analytics(document, relative)
|
|
112
|
+
verify_csp(csp, relative, comments:, analytics:, document:) unless csp.empty?
|
|
113
|
+
|
|
114
|
+
canonical = document.at_css("link[rel~='canonical']")&.[]("href")
|
|
115
|
+
expected_canonical = "#{@origin}#{public_path(route)}"
|
|
116
|
+
decoded_canonical = canonical && URI.decode_uri_component(canonical)
|
|
117
|
+
noindex = document.at_css("meta[name='robots']")&.[]("content").to_s.split(/[\s,]+/).include?("noindex")
|
|
118
|
+
unless @origin.empty?
|
|
119
|
+
if noindex
|
|
120
|
+
verify_noindex_canonical(canonical, relative)
|
|
121
|
+
elsif decoded_canonical != expected_canonical
|
|
122
|
+
add_error("#{relative}: canonical #{canonical.inspect} != #{expected_canonical.inspect}")
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
og_url = document.at_css("meta[property='og:url']")&.[]("content")
|
|
127
|
+
decoded_og_url = og_url && URI.decode_uri_component(og_url)
|
|
128
|
+
if !@origin.empty? && decoded_og_url != expected_canonical
|
|
129
|
+
add_error("#{relative}: og:url #{og_url.inspect} != #{expected_canonical.inspect}")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
if comments
|
|
133
|
+
backlink = document.at_css("meta[name='giscus:backlink']")&.[]("content")
|
|
134
|
+
decoded_backlink = backlink && URI.decode_uri_component(backlink)
|
|
135
|
+
if !@origin.empty? && decoded_backlink != expected_canonical
|
|
136
|
+
add_error("#{relative}: giscus:backlink #{backlink.inspect} != #{expected_canonical.inspect}")
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
verify_external_embeds(document, relative, comments:)
|
|
141
|
+
verify_external_media(document, relative)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
URL_ATTRIBUTES.each do |element, attribute|
|
|
145
|
+
document.css("#{element}[#{attribute}]").each do |node|
|
|
146
|
+
image = element == "img" || (element == "source" && node.ancestors.any? { |ancestor| ancestor.name == "picture" })
|
|
147
|
+
verify_reference(node[attribute], route, relative, image:, raw_html:)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
document.css("img[srcset], source[srcset]").each do |node|
|
|
151
|
+
image = node.name == "img" || node.ancestors.any? { |ancestor| ancestor.name == "picture" }
|
|
152
|
+
srcset_urls(node["srcset"]).each { |value| verify_reference(value, route, relative, image:, raw_html:) }
|
|
153
|
+
end
|
|
154
|
+
document.css("meta[name^='website:'][content]:not([name='website:analytics'])").each do |node|
|
|
155
|
+
next if raw_html
|
|
156
|
+
|
|
157
|
+
verify_reference(node["content"], route, relative)
|
|
158
|
+
end
|
|
159
|
+
rescue StandardError => exception
|
|
160
|
+
add_error("#{relative || path}: could not inspect HTML: #{exception.class}: #{exception.message}")
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def verify_reference(value, current_route, source, image: false, raw_html: false)
|
|
164
|
+
return if value.nil? || value.empty?
|
|
165
|
+
return if value.start_with?("mailto:", "tel:")
|
|
166
|
+
|
|
167
|
+
scheme = value[/\A([a-z][a-z0-9+.-]*):/i, 1]&.downcase
|
|
168
|
+
return if raw_html && (scheme || value.start_with?("//"))
|
|
169
|
+
|
|
170
|
+
if image && scheme == "http" && !same_origin_url?(value)
|
|
171
|
+
add_error("#{source}: image URL must use HTTPS: #{value.inspect}")
|
|
172
|
+
return
|
|
173
|
+
end
|
|
174
|
+
return if %w[http https].include?(scheme)
|
|
175
|
+
if scheme || value.start_with?("//")
|
|
176
|
+
add_error("#{source}: unsupported URL #{value.inspect}")
|
|
177
|
+
return
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
path_and_query, fragment = value.split("#", 2)
|
|
181
|
+
path = path_and_query.split("?", 2).first.to_s
|
|
182
|
+
if path.empty?
|
|
183
|
+
verify_fragment(source, fragment) if fragment && !raw_html
|
|
184
|
+
return
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
path = resolve_path(path, current_route)
|
|
188
|
+
unless baseurl_once?(path)
|
|
189
|
+
add_error("#{source}: baseurl is missing or repeated in #{value.inspect}")
|
|
190
|
+
return
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
route = strip_baseurl(path)
|
|
194
|
+
output = output_path_for_route(route)
|
|
195
|
+
if File.file?(output)
|
|
196
|
+
if !raw_html && fragment && !fragment.empty? && File.extname(output).downcase == ".html"
|
|
197
|
+
verify_fragment_in_file(output, fragment, source)
|
|
198
|
+
end
|
|
199
|
+
else
|
|
200
|
+
add_error("#{source}: local target does not exist for #{value.inspect}")
|
|
201
|
+
end
|
|
202
|
+
rescue ArgumentError => exception
|
|
203
|
+
add_error("#{source}: invalid URL #{value.inspect}: #{exception.message}")
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def same_origin_url?(value)
|
|
207
|
+
return false if @origin.empty?
|
|
208
|
+
|
|
209
|
+
reference = URI.parse(value)
|
|
210
|
+
origin = URI.parse(@origin)
|
|
211
|
+
reference.scheme == origin.scheme && reference.host == origin.host && reference.port == origin.port
|
|
212
|
+
rescue URI::InvalidURIError
|
|
213
|
+
false
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def verify_noindex_canonical(value, source)
|
|
217
|
+
if value.nil? || value.empty?
|
|
218
|
+
add_error("#{source}: noindex page is missing canonical")
|
|
219
|
+
return
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
canonical = URI.parse(value)
|
|
223
|
+
origin = URI.parse(@origin)
|
|
224
|
+
same_origin = canonical.scheme == origin.scheme && canonical.host == origin.host &&
|
|
225
|
+
canonical.port == origin.port && canonical.userinfo.nil?
|
|
226
|
+
unless same_origin && canonical.query.nil? && canonical.fragment.nil?
|
|
227
|
+
add_error("#{source}: noindex canonical must use the configured origin without query or fragment")
|
|
228
|
+
return
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
path = URI.decode_uri_component(canonical.path.to_s)
|
|
232
|
+
path = "/" if path.empty?
|
|
233
|
+
unless baseurl_once?(path)
|
|
234
|
+
add_error("#{source}: noindex canonical has a missing or repeated baseurl")
|
|
235
|
+
return
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
target = output_path_for_route(strip_baseurl(path))
|
|
239
|
+
unless File.file?(target) && File.extname(target).casecmp(".html").zero?
|
|
240
|
+
add_error("#{source}: canonical target does not exist: #{value.inspect}")
|
|
241
|
+
return
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
target_document = Nokogiri::HTML5.parse(File.read(target, encoding: "UTF-8"))
|
|
245
|
+
target_robots = target_document.at_css("meta[name='robots']")&.[]("content").to_s.split(/[\s,]+/)
|
|
246
|
+
add_error("#{source}: canonical target must be indexable: #{value.inspect}") if target_robots.include?("noindex")
|
|
247
|
+
rescue URI::InvalidURIError, ArgumentError => exception
|
|
248
|
+
add_error("#{source}: invalid noindex canonical #{value.inspect}: #{exception.message}")
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def verify_fragment(source, fragment)
|
|
252
|
+
return if fragment.nil? || fragment.empty?
|
|
253
|
+
path = File.join(@site_dir, source)
|
|
254
|
+
verify_fragment_in_file(path, fragment, source)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def verify_fragment_in_file(path, fragment, source)
|
|
258
|
+
decoded = URI.decode_uri_component(fragment)
|
|
259
|
+
ids = @html_ids[path] ||= Nokogiri::HTML5.parse(File.read(path, encoding: "UTF-8"))
|
|
260
|
+
.css("[id]").map { |node| node["id"] }.to_set
|
|
261
|
+
add_error("#{source}: missing fragment ##{fragment}") unless ids.include?(decoded)
|
|
262
|
+
rescue ArgumentError, Nokogiri::XML::SyntaxError
|
|
263
|
+
add_error("#{source}: invalid fragment ##{fragment}")
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def verify_csp(value, source, comments:, analytics:, document:)
|
|
267
|
+
pairs = value.split(";").filter_map do |part|
|
|
268
|
+
name, *tokens = part.strip.split(/\s+/)
|
|
269
|
+
[name, tokens] unless name.to_s.empty?
|
|
270
|
+
end
|
|
271
|
+
duplicates = pairs.group_by(&:first).select { |_name, items| items.length > 1 }.keys
|
|
272
|
+
duplicates.each { |name| add_error("#{source}: CSP directive #{name} must appear exactly once") }
|
|
273
|
+
directives = pairs.to_h
|
|
274
|
+
expected_directives = (comments ? COMMENTS_CSP_DIRECTIVES : CSP_DIRECTIVES).transform_values(&:dup)
|
|
275
|
+
ANALYTICS_CSP.fetch(analytics, {}).each do |name, tokens|
|
|
276
|
+
expected_directives[name] += tokens
|
|
277
|
+
end
|
|
278
|
+
tweet = !document.at_css("[data-website-tweet]").nil?
|
|
279
|
+
expected_directives["script-src"] << X_WIDGET_ORIGIN if tweet
|
|
280
|
+
expected_directives["media-src"] += external_origins(document, "video[src], audio[src], video source[src], audio source[src]")
|
|
281
|
+
expected_directives["frame-src"] += external_origins(document, "iframe[src]")
|
|
282
|
+
expected_directives["frame-src"] << X_WIDGET_ORIGIN if tweet
|
|
283
|
+
expected_directives.transform_values! { |tokens| tokens.uniq.sort }
|
|
284
|
+
(directives.keys - expected_directives.keys).sort.each do |name|
|
|
285
|
+
add_error("#{source}: unsupported CSP directive #{name}")
|
|
286
|
+
end
|
|
287
|
+
expected_directives.each do |name, expected|
|
|
288
|
+
actual = directives[name]
|
|
289
|
+
next if actual && actual.sort == expected.sort
|
|
290
|
+
|
|
291
|
+
add_error("#{source}: CSP directive #{name} must be exactly #{expected.join(" ")}")
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def verify_analytics(document, source)
|
|
296
|
+
nodes = document.css("meta[name='website:analytics']")
|
|
297
|
+
if nodes.length > 1
|
|
298
|
+
add_error("#{source}: website analytics meta must appear at most once")
|
|
299
|
+
return nil
|
|
300
|
+
end
|
|
301
|
+
return nil if nodes.empty?
|
|
302
|
+
|
|
303
|
+
node = nodes.first
|
|
304
|
+
provider = node["data-provider"].to_s
|
|
305
|
+
identifier = node["content"].to_s
|
|
306
|
+
unless ANALYTICS_CSP.key?(provider)
|
|
307
|
+
add_error("#{source}: unsupported website analytics provider #{provider.inspect}")
|
|
308
|
+
return nil
|
|
309
|
+
end
|
|
310
|
+
valid_identifier = if provider == "google"
|
|
311
|
+
identifier.match?(/\AG-[A-Z0-9]+\z/)
|
|
312
|
+
else
|
|
313
|
+
!identifier.empty? && identifier.length <= 256 && !identifier.match?(/[\s<>"']/)
|
|
314
|
+
end
|
|
315
|
+
add_error("#{source}: invalid #{provider} analytics identifier") unless valid_identifier
|
|
316
|
+
provider
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def verify_external_embeds(document, source, comments:)
|
|
320
|
+
document.css("script[src], iframe[src]").each do |node|
|
|
321
|
+
value = node["src"].to_s
|
|
322
|
+
uri = URI.parse(value)
|
|
323
|
+
next unless %w[http https].include?(uri.scheme)
|
|
324
|
+
|
|
325
|
+
default_port = uri.scheme == "https" ? 443 : 80
|
|
326
|
+
port = uri.port == default_port ? "" : ":#{uri.port}"
|
|
327
|
+
source_origin = "#{uri.scheme}://#{uri.host}#{port}"
|
|
328
|
+
if comments && source_origin == GISCUS_ORIGIN && (node.name == "iframe" || uri.path == "/client.js")
|
|
329
|
+
next
|
|
330
|
+
end
|
|
331
|
+
if node.name == "iframe"
|
|
332
|
+
if node["data-website-external-player"]
|
|
333
|
+
verify_player_frame(node, value, source)
|
|
334
|
+
elsif node["data-website-external-frame"] == "web"
|
|
335
|
+
verify_web_frame(node, value, source)
|
|
336
|
+
else
|
|
337
|
+
add_error("#{source}: external iframe is missing a compiler-owned marker: #{value.inspect}")
|
|
338
|
+
end
|
|
339
|
+
else
|
|
340
|
+
add_error("#{source}: external script source is not allowed: #{value.inspect}")
|
|
341
|
+
end
|
|
342
|
+
rescue URI::InvalidURIError, JekyllObsidian::ExternalMedia::Invalid
|
|
343
|
+
add_error("#{source}: invalid external #{node.name} source #{value.inspect}")
|
|
344
|
+
end
|
|
345
|
+
verify_tweets(document, source)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def verify_player_frame(node, value, source)
|
|
349
|
+
descriptor = JekyllObsidian::ExternalMedia.resolve_frame(value)
|
|
350
|
+
expected_class = "website-external-player--#{descriptor.provider}"
|
|
351
|
+
checks = {
|
|
352
|
+
"canonical src" => value == descriptor.source_url,
|
|
353
|
+
"compiler marker" => node["data-website-external-player"] == descriptor.provider.to_s,
|
|
354
|
+
"player class" => node["class"].to_s.split.include?("website-external-player") &&
|
|
355
|
+
node["class"].to_s.split.include?(expected_class),
|
|
356
|
+
"title" => !node["title"].to_s.strip.empty?,
|
|
357
|
+
"lazy loading" => node["loading"] == "lazy",
|
|
358
|
+
"referrer policy" => node["referrerpolicy"] == "strict-origin-when-cross-origin",
|
|
359
|
+
"permissions" => node["allow"].to_s == descriptor.iframe_allow.to_s,
|
|
360
|
+
"fullscreen" => node.key?("allowfullscreen")
|
|
361
|
+
}
|
|
362
|
+
checks.each do |label, valid|
|
|
363
|
+
add_error("#{source}: external iframe has invalid #{label}: #{value.inspect}") unless valid
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def verify_web_frame(node, value, source)
|
|
368
|
+
descriptor = JekyllObsidian::ExternalMedia.resolve_frame(value)
|
|
369
|
+
checks = {
|
|
370
|
+
"canonical src" => descriptor.kind == :web_frame && value == descriptor.source_url,
|
|
371
|
+
"web frame class" => node["class"].to_s.split.include?("website-external-frame__viewport"),
|
|
372
|
+
"title" => !node["title"].to_s.strip.empty?,
|
|
373
|
+
"lazy loading" => node["loading"] == "lazy",
|
|
374
|
+
"referrer policy" => node["referrerpolicy"] == "strict-origin-when-cross-origin",
|
|
375
|
+
"sandbox" => node["sandbox"] == JekyllObsidian::ExternalMedia::IFRAME_SANDBOX,
|
|
376
|
+
"height" => node["height"].to_s.match?(/\A[1-9]\d*\z/),
|
|
377
|
+
"permissions" => !node.key?("allow"),
|
|
378
|
+
"fullscreen" => node.key?("allowfullscreen")
|
|
379
|
+
}
|
|
380
|
+
checks.each do |label, valid|
|
|
381
|
+
add_error("#{source}: external iframe has invalid #{label}: #{value.inspect}") unless valid
|
|
382
|
+
end
|
|
383
|
+
dangerous = node.attribute_nodes.map(&:name).grep(/\Aon/i) + %w[srcdoc style].select { |name| node.key?(name) }
|
|
384
|
+
add_error("#{source}: external iframe retained unsafe attributes: #{dangerous.join(', ')}") unless dangerous.empty?
|
|
385
|
+
wrapper = node.ancestors("figure").find { |candidate| candidate["class"].to_s.split.include?("website-external-frame") }
|
|
386
|
+
fallback = wrapper&.at_css("a.website-external-frame__fallback")
|
|
387
|
+
unless fallback && fallback["href"] == descriptor.fallback_url && fallback["target"] == "_blank" &&
|
|
388
|
+
fallback["rel"].to_s.split.sort == %w[noopener noreferrer]
|
|
389
|
+
add_error("#{source}: external iframe is missing its canonical fallback link: #{value.inspect}")
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def verify_tweets(document, source)
|
|
394
|
+
document.css("[data-website-tweet]").each do |node|
|
|
395
|
+
identifier = node["data-website-tweet"].to_s
|
|
396
|
+
mount = node.at_css("[data-website-tweet-mount]")
|
|
397
|
+
fallback = node.at_css("a[data-website-tweet-fallback]")
|
|
398
|
+
begin
|
|
399
|
+
descriptor = JekyllObsidian::ExternalMedia.resolve(fallback&.[]("href"))
|
|
400
|
+
valid = identifier.match?(/\A[1-9]\d*\z/) && descriptor&.kind == :tweet &&
|
|
401
|
+
descriptor.identifier == identifier && mount && node.css("script, iframe").empty?
|
|
402
|
+
add_error("#{source}: Tweet embed is not canonical or contains active authored content") unless valid
|
|
403
|
+
rescue JekyllObsidian::ExternalMedia::Invalid
|
|
404
|
+
add_error("#{source}: Tweet embed has an invalid fallback URL")
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def verify_external_media(document, source)
|
|
410
|
+
document.css("video[src], audio[src], video source[src], audio source[src]").each do |node|
|
|
411
|
+
value = node["src"].to_s
|
|
412
|
+
uri = URI.parse(value)
|
|
413
|
+
next unless uri.scheme
|
|
414
|
+
unless uri.scheme == "https"
|
|
415
|
+
add_error("#{source}: external media must use HTTPS: #{value.inspect}")
|
|
416
|
+
next
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
descriptor = JekyllObsidian::ExternalMedia.resolve(value)
|
|
420
|
+
unless descriptor&.kind == :direct_video && node.name != "audio" && node.ancestors("audio").empty?
|
|
421
|
+
add_error("#{source}: external media source is not a supported direct video: #{value.inspect}")
|
|
422
|
+
end
|
|
423
|
+
rescue URI::InvalidURIError, JekyllObsidian::ExternalMedia::Invalid
|
|
424
|
+
add_error("#{source}: invalid external media source #{value.inspect}")
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def external_origins(document, selector)
|
|
429
|
+
document.css(selector).filter_map do |node|
|
|
430
|
+
JekyllObsidian::ExternalMedia.https_origin(node["src"])
|
|
431
|
+
end.uniq.sort
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def srcset_urls(value)
|
|
435
|
+
value.to_s.split(",").filter_map do |candidate|
|
|
436
|
+
url = candidate.strip.split(/\s+/, 2).first
|
|
437
|
+
url unless url.to_s.empty?
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
def verify_json_indexes
|
|
442
|
+
paths = Dir.glob(File.join(@site_dir, "assets", "website", "{,i18n/*/}{catalog,graph,search}.v1.json")).sort
|
|
443
|
+
paths.each do |path|
|
|
444
|
+
payload = JSON.parse(File.read(path, encoding: "UTF-8"))
|
|
445
|
+
add_error("#{relative_path(path)}: schema_version is not 1") unless payload["schema_version"] == 1
|
|
446
|
+
collections = [payload["notes"], payload["nodes"], payload["documents"]].compact
|
|
447
|
+
collections.flatten.each do |item|
|
|
448
|
+
next unless item.is_a?(Hash) && item["url"]
|
|
449
|
+
unless baseurl_once?(item["url"])
|
|
450
|
+
add_error("#{relative_path(path)}: bad indexed URL #{item["url"].inspect}")
|
|
451
|
+
next
|
|
452
|
+
end
|
|
453
|
+
route = strip_baseurl(item["url"])
|
|
454
|
+
add_error("#{relative_path(path)}: indexed target is missing for #{item["url"].inspect}") unless File.file?(output_path_for_route(route))
|
|
455
|
+
end
|
|
456
|
+
rescue JSON::ParserError => exception
|
|
457
|
+
add_error("#{relative_path(path)}: invalid JSON: #{exception.message}")
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
def load_raw_html_manifest
|
|
462
|
+
manifest_path = JekyllObsidian::RawHtmlManifest::PATH
|
|
463
|
+
path = File.join(@site_dir, manifest_path)
|
|
464
|
+
return unless File.file?(path)
|
|
465
|
+
|
|
466
|
+
payload = JekyllObsidian::RawHtmlManifest.parse(File.read(path, encoding: "UTF-8"))
|
|
467
|
+
payload.fetch("files").each do |route|
|
|
468
|
+
@raw_html_files << relative_path(output_path_for_route(route))
|
|
469
|
+
end
|
|
470
|
+
payload.fetch("documents").each do |document|
|
|
471
|
+
@raw_html_documents << relative_path(output_path_for_route(document.fetch("output")))
|
|
472
|
+
end
|
|
473
|
+
rescue JekyllObsidian::RawHtmlManifest::Invalid => exception
|
|
474
|
+
add_error("#{manifest_path}: unsupported manifest schema: #{exception.message}")
|
|
475
|
+
rescue ArgumentError => exception
|
|
476
|
+
add_error("#{manifest_path}: invalid manifest route: #{exception.message}")
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def verify_xml_urls
|
|
480
|
+
paths = [File.join(@site_dir, "sitemap.xml"), *Dir.glob(File.join(@site_dir, "{,*/}feed.xml"))].uniq.sort
|
|
481
|
+
paths.each do |path|
|
|
482
|
+
next unless File.file?(path)
|
|
483
|
+
name = relative_path(path)
|
|
484
|
+
document = Nokogiri::XML(File.read(path, encoding: "UTF-8")) { |config| config.strict.nonet }
|
|
485
|
+
document.remove_namespaces!
|
|
486
|
+
values = document.xpath("//loc/text() | //id/text() | //link/@href").map(&:text)
|
|
487
|
+
values.each do |value|
|
|
488
|
+
next if value.empty?
|
|
489
|
+
expected_prefix = "#{@origin}#{@baseurl}"
|
|
490
|
+
valid_prefix = !@origin.empty? && (value == expected_prefix || value.start_with?("#{expected_prefix}/"))
|
|
491
|
+
repeated = !@baseurl.empty? && value.start_with?("#{@origin}#{@baseurl}#{@baseurl}/")
|
|
492
|
+
unless valid_prefix && !repeated
|
|
493
|
+
add_error("#{name}: origin/baseurl is missing or repeated in #{value.inspect}")
|
|
494
|
+
end
|
|
495
|
+
end
|
|
496
|
+
rescue Nokogiri::XML::SyntaxError => exception
|
|
497
|
+
add_error("#{name}: invalid XML: #{exception.message}")
|
|
498
|
+
end
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
def verify_css_assets
|
|
502
|
+
Dir.glob(File.join(@site_dir, "**", "*.css")).sort.each do |path|
|
|
503
|
+
raw_html = @raw_html_files.include?(relative_path(path))
|
|
504
|
+
css = File.read(path, encoding: "UTF-8")
|
|
505
|
+
css.scan(/url\((?:"|')?([^"')]+)(?:"|')?\)/).flatten.each do |reference|
|
|
506
|
+
next if reference.start_with?("#", "data:", "http:", "https:")
|
|
507
|
+
next if raw_html && (reference.start_with?("//") || reference.match?(/\A[a-z][a-z0-9+.-]*:/i))
|
|
508
|
+
reference_path = URI.decode_uri_component(reference.split(/[?#]/, 2).first)
|
|
509
|
+
target = if reference_path.start_with?("/")
|
|
510
|
+
unless baseurl_once?(reference_path)
|
|
511
|
+
add_error("#{relative_path(path)}: baseurl is missing or repeated in CSS asset #{reference.inspect}")
|
|
512
|
+
next
|
|
513
|
+
end
|
|
514
|
+
output_path_for_route(strip_baseurl(reference_path))
|
|
515
|
+
else
|
|
516
|
+
File.expand_path(reference_path, File.dirname(path))
|
|
517
|
+
end
|
|
518
|
+
unless (target == @site_dir || target.start_with?("#{@site_dir}#{File::SEPARATOR}")) && File.file?(target)
|
|
519
|
+
add_error("#{relative_path(path)}: missing CSS asset #{reference.inspect}")
|
|
520
|
+
end
|
|
521
|
+
rescue ArgumentError
|
|
522
|
+
add_error("#{relative_path(path)}: invalid CSS asset #{reference.inspect}")
|
|
523
|
+
end
|
|
524
|
+
end
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
def resolve_path(path, current_route)
|
|
528
|
+
return path if path.start_with?("/")
|
|
529
|
+
base = current_route.end_with?("/") ? current_route : File.dirname(current_route)
|
|
530
|
+
File.join(public_path(base), path)
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
def baseurl_once?(path)
|
|
534
|
+
return path.start_with?("/") if @baseurl.empty?
|
|
535
|
+
return false unless path == @baseurl || path.start_with?("#{@baseurl}/")
|
|
536
|
+
!path.start_with?("#{@baseurl}#{@baseurl}/")
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
def strip_baseurl(path)
|
|
540
|
+
stripped = @baseurl.empty? ? path : path.delete_prefix(@baseurl)
|
|
541
|
+
stripped.empty? ? "/" : stripped
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
def public_path(route)
|
|
545
|
+
return route if @baseurl.empty?
|
|
546
|
+
return "#{@baseurl}/" if route == "/"
|
|
547
|
+
"#{@baseurl}#{route}"
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
def output_path_for_route(route)
|
|
551
|
+
decoded = URI.decode_uri_component(route.split(/[?#]/, 2).first)
|
|
552
|
+
relative = decoded.delete_prefix("/")
|
|
553
|
+
relative = if relative.empty?
|
|
554
|
+
"index.html"
|
|
555
|
+
elsif decoded.end_with?("/")
|
|
556
|
+
File.join(relative, "index.html")
|
|
557
|
+
else
|
|
558
|
+
relative
|
|
559
|
+
end
|
|
560
|
+
target = File.expand_path(relative, @site_dir)
|
|
561
|
+
raise ArgumentError, "target escapes site" unless target.start_with?("#{@site_dir}#{File::SEPARATOR}")
|
|
562
|
+
target
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
def route_for_output(relative)
|
|
566
|
+
return "/" if relative == "index.html"
|
|
567
|
+
return "/#{relative.delete_suffix("index.html")}" if relative.end_with?("/index.html")
|
|
568
|
+
"/#{relative}"
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
def relative_path(path)
|
|
572
|
+
Pathname.new(path).relative_path_from(Pathname.new(@site_dir)).to_s
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
def add_error(message)
|
|
576
|
+
@errors << message
|
|
577
|
+
end
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
unless ARGV.length == 3
|
|
581
|
+
warn "Usage: <site-dir>/scripts/verify-site-urls.rb SITE_DIR ORIGIN BASEURL"
|
|
582
|
+
exit 64
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
exit(SiteUrlVerifier.new(*ARGV).verify ? 0 : 1)
|