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,453 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "psych"
5
+
6
+ module JekyllObsidian
7
+ class FrontMatter
8
+ XML_INVALID_CHARACTER = OutputText::INVALID_CHARACTER
9
+ SUPPORTED = %w[
10
+ publish title subtitle aliases tags author categories description permalink image cssclasses created updated
11
+ content_type date pinned nav_order nav_exclude tab tabs comments github_markdown related
12
+ ].freeze
13
+ ARRAY_PROPERTIES = %w[aliases tags author categories cssclasses].freeze
14
+ LINK_ARRAY_PROPERTIES = %w[author categories].freeze
15
+ STRING_PROPERTIES = %w[title subtitle description permalink image].freeze
16
+ CONTENT_TYPES = %w[post doc page].freeze
17
+ TAB_KEYS = %w[id label order topics].freeze
18
+ TAB_ID_PATTERN = /\A[a-z][a-z0-9-]*\z/.freeze
19
+
20
+ Result = Struct.new(:properties, :property_links, :body, :diagnostics, keyword_init: true)
21
+
22
+ def self.parse(path, bytes)
23
+ new(path, bytes).parse
24
+ end
25
+
26
+ def self.valid_output_text?(value)
27
+ OutputText.valid?(value)
28
+ end
29
+
30
+ def self.parse_wiki_link(value)
31
+ return unless value.is_a?(String)
32
+
33
+ match = value.match(/\A\[\[([^\[\]\r\n]+)\]\]\z/u)
34
+ return unless match
35
+
36
+ target, display = match[1].split("|", 2).map { |part| part&.strip }
37
+ return if target.to_s.empty? || display == ""
38
+
39
+ [target, display]
40
+ end
41
+
42
+ def initialize(path, bytes)
43
+ @path = path
44
+ @bytes = bytes.dup.force_encoding(Encoding::UTF_8)
45
+ @diagnostics = []
46
+ end
47
+
48
+ def parse
49
+ unless @bytes.valid_encoding?
50
+ error("invalid_utf8", "note must be valid UTF-8")
51
+ return result({}, [], "")
52
+ end
53
+
54
+ raw, body = split
55
+ return result({}, [], body) unless raw
56
+
57
+ parsed = Psych.safe_load(raw, permitted_classes: [Date, Time], aliases: false) || {}
58
+ unless parsed.is_a?(Hash) && parsed.keys.all? { |key| key.is_a?(String) }
59
+ error("invalid_frontmatter", "frontmatter must be a YAML mapping with string keys")
60
+ return result({}, [], body)
61
+ end
62
+
63
+ properties, property_links = validate(parsed, raw)
64
+ result(properties, property_links, body)
65
+ rescue Psych::Exception => exception
66
+ error("invalid_frontmatter", "invalid YAML frontmatter: #{exception.problem || exception.message}")
67
+ result({}, [], body || "")
68
+ end
69
+
70
+ private
71
+
72
+ def split
73
+ return [nil, @bytes] unless @bytes.start_with?("---\n", "---\r\n")
74
+
75
+ lines = @bytes.lines
76
+ closing = lines.each_index.drop(1).find { |index| %W[---\n ---\r\n ...\n ...\r\n].include?(lines[index]) }
77
+ unless closing
78
+ error("invalid_frontmatter", "frontmatter is missing a closing delimiter")
79
+ return ["", ""]
80
+ end
81
+
82
+ [lines[1...closing].join, lines[(closing + 1)..].to_a.join]
83
+ end
84
+
85
+ def validate(parsed, raw)
86
+ unquoted_wiki_links = unquoted_wiki_link_properties(raw)
87
+ property_nodes, property_key_nodes = frontmatter_nodes(raw)
88
+ properties = {}
89
+ property_links = []
90
+ parsed.each do |key, value|
91
+ unless SUPPORTED.include?(key)
92
+ unless valid_custom_property_name?(key)
93
+ error(
94
+ "invalid_property",
95
+ "custom property names must be non-empty NFC text without leading, trailing, or control characters",
96
+ span: frontmatter_source_span(property_key_nodes[key])
97
+ )
98
+ next
99
+ end
100
+
101
+ valid, normalized = normalize_custom_property(key, value, property_nodes[key])
102
+ next unless valid
103
+
104
+ properties[key] = normalized
105
+ unless key == "alias"
106
+ property_links.concat(custom_property_links(key, normalized, property_nodes[key]))
107
+ end
108
+ next
109
+ end
110
+
111
+ case key
112
+ when "publish", "comments", "pinned"
113
+ if value == true || value == false
114
+ properties[key] = value
115
+ else
116
+ code = case key
117
+ when "publish" then "invalid_publish"
118
+ when "comments" then "invalid_comments"
119
+ else "invalid_property"
120
+ end
121
+ error(code, "#{key} must be a YAML boolean")
122
+ end
123
+ when *ARRAY_PROPERTIES
124
+ if value.is_a?(Array) && value.all? { |item| self.class.valid_output_text?(item) }
125
+ if LINK_ARRAY_PROPERTIES.include?(key) && value.any? { |item| wiki_link_candidate?(item) && !self.class.parse_wiki_link(item) }
126
+ error("invalid_property", "#{key} wiki links must use [[target]] or [[target|label]] syntax")
127
+ elsif unquoted_wiki_links.include?(key)
128
+ error("invalid_property", "#{key} wiki link entries must use double-quoted YAML strings")
129
+ elsif key == "cssclasses" && !value.all? { |item| item.match?(/\A[-_a-zA-Z][-_a-zA-Z0-9]*\z/) }
130
+ error("invalid_property", "cssclasses entries must be valid CSS class tokens")
131
+ else
132
+ properties[key] = value.dup
133
+ end
134
+ else
135
+ error("invalid_property", "#{key} must be an array of strings")
136
+ end
137
+ when "related"
138
+ normalized, links = validate_related(value, property_nodes[key])
139
+ if normalized
140
+ properties[key] = normalized
141
+ property_links.concat(links)
142
+ end
143
+ when *STRING_PROPERTIES
144
+ if self.class.valid_output_text?(value)
145
+ properties[key] = value
146
+ else
147
+ error("invalid_property", "#{key} must be a string containing only output-safe Unicode characters")
148
+ end
149
+ when "content_type"
150
+ if CONTENT_TYPES.include?(value)
151
+ properties[key] = value
152
+ else
153
+ error("invalid_property", "content_type must be one of: #{CONTENT_TYPES.join(', ')}")
154
+ end
155
+ when "created", "updated", "date"
156
+ normalized = normalize_time(value)
157
+ normalized ? properties[key] = normalized : error("invalid_property", "#{key} must be an ISO-8601 date or date-time")
158
+ when "nav_order"
159
+ if value.is_a?(Integer)
160
+ properties[key] = value
161
+ else
162
+ error("invalid_property", "nav_order must be an integer")
163
+ end
164
+ when "nav_exclude"
165
+ if value == true || value == false
166
+ properties[key] = value
167
+ else
168
+ error("invalid_property", "nav_exclude must be a YAML boolean")
169
+ end
170
+ when "tab"
171
+ normalized = validate_tab(value)
172
+ properties[key] = normalized if normalized
173
+ when "tabs"
174
+ normalized = validate_tabs(value)
175
+ properties[key] = normalized if normalized
176
+ when "github_markdown"
177
+ begin
178
+ reference = GitHubMarkdown.normalize(value)
179
+ properties[key] = {
180
+ "repository" => reference.repository,
181
+ "ref" => reference.ref,
182
+ "path" => reference.path
183
+ }
184
+ rescue GitHubMarkdown::Invalid => exception
185
+ error("invalid_property", exception.message)
186
+ end
187
+ end
188
+ end
189
+ [properties, property_links]
190
+ end
191
+
192
+ def valid_custom_property_name?(value)
193
+ self.class.valid_output_text?(value) &&
194
+ !value.empty? &&
195
+ !value.match?(/\A\p{Space}|\p{Space}\z/u) &&
196
+ value == value.unicode_normalize(:nfc) &&
197
+ !value.match?(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u)
198
+ rescue EncodingError
199
+ false
200
+ end
201
+
202
+ def normalize_custom_property(key, value, property_node)
203
+ if value.is_a?(Array)
204
+ if value.all? { |item| custom_scalar?(item) }
205
+ return [true, value.map { |item| normalize_custom_scalar(item) }]
206
+ end
207
+ elsif custom_scalar?(value)
208
+ return [true, normalize_custom_scalar(value)]
209
+ end
210
+
211
+ if unquoted_wiki_link_node?(property_node)
212
+ error("invalid_property", "#{key} wiki links must use double-quoted YAML strings")
213
+ else
214
+ error("invalid_property", "#{key} must be a scalar or a flat array of scalars")
215
+ end
216
+ [false, nil]
217
+ end
218
+
219
+ def validate_related(value, property_node)
220
+ unless value.is_a?(Array)
221
+ error("invalid_property", "related must be an array of double-quoted wiki links")
222
+ return [nil, []]
223
+ end
224
+
225
+ nodes = property_node.is_a?(Psych::Nodes::Sequence) ? property_node.children : []
226
+ links = []
227
+ invalid = false
228
+ value.each_with_index do |item, index|
229
+ if item.is_a?(String) && !self.class.valid_output_text?(item)
230
+ error("invalid_property", "related entries must contain only output-safe Unicode characters")
231
+ invalid = true
232
+ next
233
+ end
234
+
235
+ parsed = self.class.parse_wiki_link(item)
236
+ node = nodes[index]
237
+ unless parsed
238
+ message = if unquoted_wiki_link_node?(node)
239
+ "related wiki links must use double-quoted YAML strings"
240
+ else
241
+ "related entries must use [[target]] or [[target|label]] syntax"
242
+ end
243
+ error("invalid_property", message)
244
+ invalid = true
245
+ next
246
+ end
247
+ unless node.is_a?(Psych::Nodes::Scalar) && node.style == Psych::Nodes::Scalar::DOUBLE_QUOTED
248
+ error("invalid_property", "related wiki links must use double-quoted YAML strings")
249
+ invalid = true
250
+ next
251
+ end
252
+
253
+ target, display = parsed
254
+ links << FrontMatterLink.new(
255
+ property: "related",
256
+ index: index,
257
+ target: target,
258
+ display: display,
259
+ source_span: frontmatter_source_span(node)
260
+ )
261
+ end
262
+ invalid ? [nil, []] : [value.dup, links]
263
+ end
264
+
265
+ def unquoted_wiki_link_node?(node)
266
+ return false unless node.is_a?(Psych::Nodes::Sequence)
267
+
268
+ node.children.any? do |child|
269
+ child.is_a?(Psych::Nodes::Sequence) &&
270
+ (child.children.all? { |nested| nested.is_a?(Psych::Nodes::Scalar) } || unquoted_wiki_link_node?(child))
271
+ end
272
+ end
273
+
274
+ def custom_scalar?(value)
275
+ return true if value.nil? || value == true || value == false || value.is_a?(Integer)
276
+ return value.finite? if value.is_a?(Float)
277
+ return true if value.is_a?(Date) || value.is_a?(DateTime) || value.is_a?(Time)
278
+
279
+ self.class.valid_output_text?(value)
280
+ end
281
+
282
+ def normalize_custom_scalar(value)
283
+ return normalize_time(value) if value.is_a?(Date) || value.is_a?(DateTime) || value.is_a?(Time)
284
+
285
+ value
286
+ end
287
+
288
+ def custom_property_links(property, value, property_node)
289
+ values = value.is_a?(Array) ? value : [value]
290
+ nodes = if property_node.is_a?(Psych::Nodes::Sequence)
291
+ property_node.children
292
+ else
293
+ [property_node]
294
+ end
295
+
296
+ values.each_with_index.filter_map do |item, index|
297
+ next unless item.is_a?(String)
298
+
299
+ parsed = self.class.parse_wiki_link(item)
300
+ if parsed
301
+ node = nodes[index]
302
+ unless node.is_a?(Psych::Nodes::Scalar) && node.style == Psych::Nodes::Scalar::DOUBLE_QUOTED
303
+ error("invalid_property", "#{property} wiki links must use double-quoted YAML strings")
304
+ next
305
+ end
306
+
307
+ target, display = parsed
308
+ FrontMatterLink.new(
309
+ property: property,
310
+ index: index,
311
+ target: target,
312
+ display: display,
313
+ source_span: frontmatter_source_span(node)
314
+ )
315
+ elsif item.start_with?("[[")
316
+ error("invalid_property", "#{property} wiki links must use [[target]] or [[target|label]] syntax")
317
+ nil
318
+ end
319
+ end
320
+ end
321
+
322
+ def frontmatter_nodes(raw)
323
+ mapping = Psych.parse(raw)&.root
324
+ return [{}, {}] unless mapping.is_a?(Psych::Nodes::Mapping)
325
+
326
+ property_nodes = {}
327
+ property_key_nodes = {}
328
+ mapping.children.each_slice(2) do |key_node, value_node|
329
+ next unless key_node.is_a?(Psych::Nodes::Scalar)
330
+
331
+ property_nodes[key_node.value] = value_node
332
+ property_key_nodes[key_node.value] = key_node
333
+ end
334
+ [property_nodes, property_key_nodes]
335
+ end
336
+
337
+ def frontmatter_source_span(node)
338
+ SourceSpan.new(
339
+ start_line: node.start_line + 2,
340
+ start_column: node.start_column + 1,
341
+ end_line: node.end_line + 2,
342
+ end_column: node.end_column + 1
343
+ )
344
+ end
345
+
346
+ def validate_tab(value)
347
+ unless value.is_a?(Hash) && value.keys.all? { |key| key.is_a?(String) }
348
+ error("invalid_property", "tab must be a YAML mapping with string keys")
349
+ return nil
350
+ end
351
+
352
+ unknown = value.keys - TAB_KEYS
353
+ unknown.sort.each { |key| error("invalid_property", "unknown tab setting #{key.inspect}") }
354
+ normalized = {}
355
+ if value.key?("id")
356
+ id = value["id"]
357
+ if valid_tab_id?(id)
358
+ normalized["id"] = id
359
+ else
360
+ error("invalid_property", "tab.id must be a lowercase ASCII identifier")
361
+ end
362
+ end
363
+ if value.key?("label")
364
+ label = value["label"]
365
+ if self.class.valid_output_text?(label) && !label.strip.empty?
366
+ normalized["label"] = label
367
+ else
368
+ error("invalid_property", "tab.label must be a non-empty string containing only output-safe Unicode characters")
369
+ end
370
+ end
371
+ if value.key?("order")
372
+ order = value["order"]
373
+ if order.is_a?(Integer)
374
+ normalized["order"] = order
375
+ else
376
+ error("invalid_property", "tab.order must be an integer")
377
+ end
378
+ end
379
+ if value.key?("topics")
380
+ topics = value["topics"]
381
+ if topics.is_a?(Array) && topics.all? { |topic| valid_tab_topic?(topic) }
382
+ normalized["topics"] = topics.map(&:strip)
383
+ else
384
+ error("invalid_property", "tab.topics must be an array of non-empty strings")
385
+ end
386
+ end
387
+ normalized
388
+ end
389
+
390
+ def validate_tabs(value)
391
+ unless value.is_a?(Array) && value.all? { |id| valid_tab_id?(id) } && value.uniq.length == value.length
392
+ error("invalid_property", "tabs entries must be unique lowercase ASCII identifiers")
393
+ return nil
394
+ end
395
+
396
+ value.dup
397
+ end
398
+
399
+ def valid_tab_id?(value)
400
+ value.is_a?(String) && value.match?(TAB_ID_PATTERN)
401
+ end
402
+
403
+ def valid_tab_topic?(value)
404
+ self.class.valid_output_text?(value) && !value.strip.empty? && value == value.unicode_normalize(:nfc)
405
+ rescue EncodingError
406
+ false
407
+ end
408
+
409
+ def wiki_link_candidate?(value)
410
+ value.start_with?("[[") || value.end_with?("]]")
411
+ end
412
+
413
+ def unquoted_wiki_link_properties(raw)
414
+ mapping = Psych.parse(raw)&.root
415
+ return [] unless mapping.is_a?(Psych::Nodes::Mapping)
416
+
417
+ mapping.children.each_slice(2).filter_map do |key_node, value_node|
418
+ next unless key_node.is_a?(Psych::Nodes::Scalar) && LINK_ARRAY_PROPERTIES.include?(key_node.value)
419
+ next unless value_node.is_a?(Psych::Nodes::Sequence)
420
+
421
+ invalid = value_node.children.any? do |item|
422
+ item.is_a?(Psych::Nodes::Scalar) &&
423
+ self.class.parse_wiki_link(item.value) &&
424
+ item.style != Psych::Nodes::Scalar::DOUBLE_QUOTED
425
+ end
426
+ key_node.value if invalid
427
+ end
428
+ end
429
+
430
+ def normalize_time(value)
431
+ case value
432
+ when DateTime, Time
433
+ value.iso8601
434
+ when Date
435
+ "#{value.iso8601}T00:00:00Z"
436
+ when String
437
+ return "#{Date.iso8601(value).iso8601}T00:00:00Z" if value.match?(/\A\d{4}-\d{2}-\d{2}\z/)
438
+
439
+ DateTime.iso8601(value).iso8601
440
+ end
441
+ rescue Date::Error
442
+ nil
443
+ end
444
+
445
+ def error(code, message, span: nil)
446
+ @diagnostics << Diagnostic.new(severity: :error, code: code, message: message, path: @path, span: span)
447
+ end
448
+
449
+ def result(properties, property_links, body)
450
+ Result.new(properties: properties, property_links: property_links, body: body, diagnostics: @diagnostics)
451
+ end
452
+ end
453
+ end