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