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,517 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JekyllObsidian
4
+ # Compiler-owned navigation projection. It turns built-in content ownership
5
+ # and authored custom-tab declarations into one immutable model consumed by
6
+ # every presentation surface.
7
+ module SiteNavigation
8
+ PortfolioProjection = ImmutableRecord.define(
9
+ :path,
10
+ :route,
11
+ :index_note_id,
12
+ :project_note_ids
13
+ )
14
+
15
+ CustomTabProjection = ImmutableRecord.define(
16
+ :id,
17
+ :path,
18
+ :route,
19
+ :index_note_id,
20
+ :member_note_ids
21
+ )
22
+
23
+ Result = ImmutableRecord.define(
24
+ :items,
25
+ :active_by_note_id,
26
+ :docs_tree,
27
+ :docs_note_ids,
28
+ :docs_home_url,
29
+ :portfolio,
30
+ :custom_tabs,
31
+ :diagnostics
32
+ )
33
+
34
+ module_function
35
+
36
+ def build(model:, settings:, content:, url_builder:, theme:, member_ids_by_tab_id: nil)
37
+ Builder.new(
38
+ model: model,
39
+ settings: settings,
40
+ content: content,
41
+ url_builder: url_builder,
42
+ theme: theme,
43
+ member_ids_by_tab_id: member_ids_by_tab_id
44
+ ).build
45
+ end
46
+
47
+ class Builder
48
+ def initialize(model:, settings:, content:, url_builder:, theme:, member_ids_by_tab_id:)
49
+ @model = model
50
+ @settings = settings
51
+ @content = content
52
+ @url_builder = url_builder
53
+ @theme = theme
54
+ @member_ids_by_tab_id = member_ids_by_tab_id
55
+ @diagnostics = []
56
+ end
57
+
58
+ def build
59
+ documentation = documentation_navigation
60
+ portfolio = @theme == "minimal" ? portfolio_projection : nil
61
+ @portfolio_projection = portfolio
62
+ custom_tabs = custom_tab_projections(portfolio)
63
+ @custom_tabs = custom_tabs
64
+ items = if @theme == "minimal"
65
+ minimal_items(documentation, portfolio, custom_tabs)
66
+ else
67
+ docs_items(documentation, custom_tabs)
68
+ end
69
+ items = validate_items(items)
70
+ active = active_notes(items)
71
+
72
+ Result.new(
73
+ items: items,
74
+ active_by_note_id: active,
75
+ docs_tree: documentation.fetch("tree"),
76
+ docs_note_ids: documentation.fetch("linked_notes").map(&:id),
77
+ docs_home_url: documentation.fetch("home_url"),
78
+ portfolio: portfolio,
79
+ custom_tabs: custom_tabs,
80
+ diagnostics: @diagnostics
81
+ )
82
+ end
83
+
84
+ private
85
+
86
+ def minimal_items(documentation, portfolio, custom_tabs)
87
+ items = []
88
+ posts = @model.notes.select { |note| note.content_type == "post" }
89
+ docs = @model.notes.select { |note| note.content_type == "doc" }
90
+ root = directory_index(".")
91
+ portfolio_owns_root_navigation = portfolio&.route == "/" && @settings.fetch("portfolio").fetch("visible")
92
+
93
+ add_builtin(
94
+ items,
95
+ "home",
96
+ "Home",
97
+ @url_builder.href("/"),
98
+ !portfolio_owns_root_navigation && (root || posts.any?),
99
+ note_ids: root ? [root.id] : [],
100
+ routes: ["/"]
101
+ )
102
+ add_builtin(
103
+ items,
104
+ "blog",
105
+ "Blog",
106
+ @url_builder.href("/blog/"),
107
+ posts.any?,
108
+ note_ids: posts.map(&:id),
109
+ routes: ["/blog/"]
110
+ )
111
+ add_builtin(
112
+ items,
113
+ "docs",
114
+ "Docs",
115
+ documentation.fetch("home_url"),
116
+ !documentation.fetch("linked_notes").empty?,
117
+ note_ids: docs.map(&:id),
118
+ routes: []
119
+ )
120
+ if portfolio
121
+ add_builtin(
122
+ items,
123
+ "portfolio",
124
+ "Portfolio",
125
+ @url_builder.href(portfolio.route),
126
+ true,
127
+ note_ids: @portfolio_note_ids,
128
+ routes: [portfolio.route]
129
+ )
130
+ end
131
+ add_custom_tab_items(items, custom_tabs)
132
+ items
133
+ end
134
+
135
+ def docs_items(documentation, custom_tabs)
136
+ items = []
137
+ root = directory_index(".")
138
+ docs = @model.notes.select { |note| note.content_type == "doc" }
139
+ add_builtin(
140
+ items,
141
+ "home",
142
+ "Overview",
143
+ @url_builder.href("/"),
144
+ !root.nil?,
145
+ note_ids: root ? [root.id] : [],
146
+ routes: ["/"]
147
+ )
148
+ add_builtin(
149
+ items,
150
+ "docs",
151
+ "Documentation",
152
+ documentation.fetch("home_url"),
153
+ !documentation.fetch("linked_notes").empty?,
154
+ note_ids: docs.map(&:id),
155
+ routes: []
156
+ )
157
+ add_custom_tab_items(items, custom_tabs)
158
+ items
159
+ end
160
+
161
+ def add_builtin(items, id, default_label, route, has_content, note_ids:, routes:)
162
+ setting = @settings.fetch(id)
163
+ return unless setting.fetch("visible") && has_content && route
164
+
165
+ items << {
166
+ "id" => id,
167
+ "label" => setting["label"] || default_label,
168
+ "url" => route,
169
+ "order" => setting.fetch("order"),
170
+ "active_scope" => active_scope(note_ids, routes)
171
+ }
172
+ end
173
+
174
+ def add_custom_tab_items(items, custom_tabs)
175
+ custom_tabs.each do |tab|
176
+ root = @model.notes_by_id.fetch(tab.index_note_id)
177
+ authored = root.properties.fetch("tab")
178
+ active_note_ids = @model.notes.select do |note|
179
+ note.content_type == "page" &&
180
+ !Array(@portfolio_note_ids).include?(note.id) &&
181
+ (note.id == tab.index_note_id || note.id.start_with?("#{tab.path}/"))
182
+ end.map(&:id)
183
+ items << {
184
+ "id" => tab.id,
185
+ "label" => authored["label"] || root.title,
186
+ "url" => @url_builder.href(tab.route),
187
+ "order" => authored.fetch("order", 100),
188
+ "active_scope" => active_scope(active_note_ids, [])
189
+ }
190
+ end
191
+ end
192
+
193
+ def validate_items(items)
194
+ candidates = items.filter_map do |item|
195
+ label = item["label"]
196
+ unless FrontMatter.valid_output_text?(label) && !label.strip.empty?
197
+ navigation_error(
198
+ "invalid_navigation_label",
199
+ "navigation label must be a non-empty string containing only output-safe Unicode characters",
200
+ navigation_source(item)
201
+ )
202
+ next
203
+ end
204
+
205
+ item.merge("label" => label.strip)
206
+ end
207
+ valid = []
208
+ labels = {}
209
+ destinations = {}
210
+ candidates.sort_by { |item| [item.fetch("order"), item.fetch("id")] }.each do |item|
211
+ label_key = item.fetch("label").unicode_normalize(:nfc).downcase(:fold)
212
+ destination_key = item.fetch("url").unicode_normalize(:nfc).downcase(:fold)
213
+ if labels.key?(label_key)
214
+ navigation_error(
215
+ "duplicate_navigation_label",
216
+ "navigation labels must be unique; conflicts with #{labels.fetch(label_key).inspect}",
217
+ navigation_source(item)
218
+ )
219
+ next
220
+ end
221
+ if destinations.key?(destination_key)
222
+ navigation_error(
223
+ "duplicate_navigation_target",
224
+ "navigation targets must be unique; conflicts with #{destinations.fetch(destination_key).inspect}",
225
+ navigation_source(item)
226
+ )
227
+ next
228
+ end
229
+
230
+ labels[label_key] = item.fetch("id")
231
+ destinations[destination_key] = item.fetch("id")
232
+ valid << item
233
+ rescue EncodingError
234
+ navigation_error("invalid_navigation_label", "navigation label must contain valid Unicode", navigation_source(item))
235
+ end
236
+ valid
237
+ end
238
+
239
+ def active_notes(items)
240
+ active = {}
241
+ builtins = items.select { |item| %w[home blog docs portfolio].include?(item.fetch("id")) }
242
+ custom_ids = @custom_tabs.map(&:id)
243
+ custom = items.select { |item| custom_ids.include?(item.fetch("id")) }.sort_by do |item|
244
+ tab = @custom_tabs.find { |candidate| candidate.id == item.fetch("id") }
245
+ [tab.path.count("/"), tab.path, tab.id]
246
+ end
247
+ (builtins + custom).each do |item|
248
+ item.fetch("active_scope").fetch("note_ids").each do |note_id|
249
+ active[note_id] = item.fetch("id")
250
+ end
251
+ end
252
+ active
253
+ end
254
+
255
+ def active_scope(note_ids, routes)
256
+ {
257
+ "note_ids" => note_ids.uniq.sort,
258
+ "routes" => routes.uniq.sort
259
+ }
260
+ end
261
+
262
+ def directory_index(directory)
263
+ path = @model.directory_index_paths&.fetch(directory, nil)
264
+ path && @model.notes_by_id[path]
265
+ end
266
+
267
+ def portfolio_projection
268
+ path = @settings.fetch("portfolio").fetch("path")
269
+ notes = @model.notes.select { |note| note.id.start_with?("#{path}/") }
270
+ index = directory_index(path)
271
+ projects = notes.reject { |note| note.id == index&.id || note.nav_exclude }.sort_by do |note|
272
+ portfolio_sort_key(note)
273
+ end
274
+ return if projects.empty?
275
+
276
+ @portfolio_note_ids = notes.map(&:id)
277
+ PortfolioProjection.new(
278
+ path: path,
279
+ route: index ? index.route : @url_builder.route_for_note("#{path}/index.md"),
280
+ index_note_id: index&.id,
281
+ project_note_ids: projects.map(&:id)
282
+ )
283
+ end
284
+
285
+ def custom_tab_projections(portfolio)
286
+ definitions = @model.notes.select { |note| note.properties.key?("tab") }.sort_by(&:id)
287
+ valid = []
288
+ seen = {}
289
+ definitions.each do |note|
290
+ authored = note.properties.fetch("tab")
291
+ id = authored["id"]
292
+ unless id
293
+ navigation_error("invalid_tab_id", "tab.id is required on a custom tab root", note.id)
294
+ next
295
+ end
296
+ if %w[home blog docs portfolio].include?(id)
297
+ navigation_error("reserved_tab_id", "tab.id conflicts with a built-in navigation id", note.id)
298
+ next
299
+ end
300
+ if seen.key?(id)
301
+ navigation_error("duplicate_tab_id", "tab.id is already declared by #{seen.fetch(id)}", note.id)
302
+ next
303
+ end
304
+ seen[id] = note.id
305
+ unless note.content_type == "page" && directory_index_note?(note) && File.dirname(note.id) != "." && !note.nav_exclude
306
+ navigation_error(
307
+ "invalid_tab_root",
308
+ "tab must be declared by a visible content_type: page folder index",
309
+ note.id
310
+ )
311
+ next
312
+ end
313
+ if portfolio && note.id.start_with?("#{portfolio.path}/")
314
+ navigation_error(
315
+ "tab_conflicts_with_portfolio",
316
+ "custom tab roots cannot be inside the built-in portfolio path",
317
+ note.id
318
+ )
319
+ next
320
+ end
321
+
322
+ valid << [note, id, File.dirname(note.id)]
323
+ end
324
+
325
+ known_ids = valid.map { |_, id, _| id }
326
+ @model.notes.each do |note|
327
+ memberships = Array(note.properties["tabs"])
328
+ if note.nav_exclude && !memberships.empty?
329
+ navigation_error(
330
+ "invalid_tab_membership",
331
+ "nav_exclude pages cannot be added to custom tabs",
332
+ note.id
333
+ )
334
+ end
335
+ (memberships - known_ids).each do |id|
336
+ navigation_error("unknown_tab", "tabs references an unknown custom tab #{id.inspect}", note.id)
337
+ end
338
+ end
339
+
340
+ valid.map do |root, id, path|
341
+ members = custom_tab_members(root, id, path)
342
+ CustomTabProjection.new(
343
+ id: id,
344
+ path: path,
345
+ route: root.route,
346
+ index_note_id: root.id,
347
+ member_note_ids: members.map(&:id)
348
+ )
349
+ end
350
+ end
351
+
352
+ def custom_tab_members(root, id, path)
353
+ if @member_ids_by_tab_id&.key?(id)
354
+ return @member_ids_by_tab_id.fetch(id).filter_map do |note_id|
355
+ note = @model.notes_by_id[note_id]
356
+ note if note && note.id != root.id && !note.nav_exclude
357
+ end.sort_by { |note| custom_tab_sort_key(note) }
358
+ end
359
+
360
+ topic_ids = Array(root.properties.dig("tab", "topics")).map { |topic| topic_name_identity(topic) }
361
+ @model.notes.select do |note|
362
+ next false if note.id == root.id || note.nav_exclude
363
+
364
+ in_root = note.id.start_with?("#{path}/")
365
+ explicit = Array(note.properties["tabs"]).include?(id)
366
+ topic = !topic_ids.empty? && authored_topic_names(note).any? { |name| topic_ids.include?(name) }
367
+ in_root || explicit || topic
368
+ end.sort_by { |note| custom_tab_sort_key(note) }
369
+ end
370
+
371
+ def authored_topic_names(note)
372
+ note.topics
373
+ .select { |topic| %w[tag category].include?(topic.fetch("kind")) }
374
+ .map { |topic| topic_name_identity(topic.fetch("name")) }
375
+ end
376
+
377
+ def topic_name_identity(topic)
378
+ topic.unicode_normalize(:nfc).downcase(:fold)
379
+ end
380
+
381
+ def custom_tab_sort_key(note)
382
+ pinned = note.properties["pinned"] == true
383
+ order = note.nav_order
384
+ [pinned ? 0 : 1, order.nil? ? 1 : 0, order || 0, note.title.downcase, note.title, note.id]
385
+ end
386
+
387
+ def portfolio_sort_key(note)
388
+ order = note.nav_order
389
+ pinned = note.properties["pinned"] == true
390
+ [pinned ? 0 : 1, order.nil? ? 1 : 0, order || 0, note.title.downcase, note.title, note.id]
391
+ end
392
+
393
+ def navigation_source(item)
394
+ id = item.fetch("id")
395
+ custom = @custom_tabs&.find { |tab| tab.id == id }
396
+ return custom.index_note_id if custom
397
+
398
+ "website.navigation.#{id}"
399
+ end
400
+
401
+ def documentation_navigation
402
+ root = { path: "", name: "", index: nil, notes: [], children: {} }
403
+ docs_directories = @content.fetch("directories").fetch("doc")
404
+ single_root = docs_directories.first if docs_directories.one?
405
+ @model.notes.select { |note| note.content_type == "doc" }.sort_by(&:id).each do |note|
406
+ navigation_id = if single_root && note.id.start_with?("#{single_root}/")
407
+ note.id.delete_prefix("#{single_root}/")
408
+ else
409
+ note.id
410
+ end
411
+ directory = File.dirname(navigation_id)
412
+ folder = root
413
+ unless directory == "."
414
+ current_path = []
415
+ directory.split("/").each do |segment|
416
+ current_path << segment
417
+ folder[:children][segment] ||= {
418
+ path: current_path.join("/"), name: segment, index: nil, notes: [], children: {}
419
+ }
420
+ folder = folder[:children].fetch(segment)
421
+ end
422
+ end
423
+ if directory_index_note?(note)
424
+ folder[:index] = note
425
+ else
426
+ folder[:notes] << note
427
+ end
428
+ end
429
+
430
+ tree = render_folder_contents(root)
431
+ linked = []
432
+ root_index = root.fetch(:index)
433
+ linked << root_index if root_index && !root_index.nav_exclude
434
+ walk = lambda do |nodes|
435
+ nodes.each do |node|
436
+ linked << @model.notes_by_id.fetch(node.fetch("id")) if node["url"] && @model.notes_by_id.key?(node.fetch("id"))
437
+ walk.call(node.fetch("children"))
438
+ end
439
+ end
440
+ walk.call(tree)
441
+
442
+ configured_root = docs_directories.filter_map do |directory|
443
+ candidate = directory_index(directory)
444
+ candidate if candidate&.content_type == "doc" && !candidate.nav_exclude
445
+ end.first
446
+ landing = configured_root || linked.first
447
+ {
448
+ "tree" => tree,
449
+ "linked_notes" => linked,
450
+ "home_url" => landing && @url_builder.href(landing.route)
451
+ }
452
+ end
453
+
454
+ def render_folder_contents(folder)
455
+ nodes = folder.fetch(:notes).reject(&:nav_exclude).map { |note| docs_node(note, []) }
456
+ folder.fetch(:children).values.each do |child|
457
+ children = render_folder_contents(child)
458
+ index_note = child.fetch(:index)
459
+ next if children.empty? && (!index_note || index_note.nav_exclude)
460
+
461
+ node = if index_note
462
+ docs_node(index_note, children, linked: !index_note.nav_exclude)
463
+ else
464
+ {
465
+ "id" => "folder:#{child.fetch(:path)}",
466
+ "title" => humanize_path_segment(child.fetch(:name)),
467
+ "url" => first_link_url(children),
468
+ "content_type" => "doc",
469
+ "children" => children,
470
+ "nav_order" => nil
471
+ }
472
+ end
473
+ nodes << node
474
+ end
475
+ nodes.sort_by { |node| docs_sort_key(node) }
476
+ end
477
+
478
+ def directory_index_note?(note)
479
+ @model.directory_index_paths&.fetch(File.dirname(note.id), nil) == note.id
480
+ end
481
+
482
+ def first_link_url(nodes)
483
+ nodes.each do |node|
484
+ return node["url"] if node["url"]
485
+
486
+ nested = first_link_url(node.fetch("children"))
487
+ return nested if nested
488
+ end
489
+ nil
490
+ end
491
+
492
+ def docs_node(note, children, linked: true)
493
+ {
494
+ "id" => note.id,
495
+ "title" => note.title,
496
+ "url" => linked ? @url_builder.href(note.route) : nil,
497
+ "content_type" => note.content_type,
498
+ "children" => children,
499
+ "nav_order" => note.nav_order
500
+ }
501
+ end
502
+
503
+ def docs_sort_key(node)
504
+ order = node["nav_order"]
505
+ [order.nil? ? 1 : 0, order || 0, node.fetch("title").downcase, node.fetch("id")]
506
+ end
507
+
508
+ def humanize_path_segment(segment)
509
+ segment.tr("-_", " ").split.map(&:capitalize).join(" ")
510
+ end
511
+
512
+ def navigation_error(code, message, path)
513
+ @diagnostics << Diagnostic.new(severity: :error, code: code, message: message, path: path, span: nil)
514
+ end
515
+ end
516
+ end
517
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module JekyllObsidian
6
+ class UrlBuilder
7
+ INVALID_PERMALINK = %r{(?:\A(?!/)|://|[?#]|(?:\A|/)\.\.(?:/|\z)|:[a-z_]+)}i
8
+ ENCODED_SEPARATOR = /%(?:2f|5c)/i
9
+ CONTROL_CHARACTER = /[[:cntrl:]]/
10
+
11
+ attr_reader :origin, :baseurl
12
+
13
+ def initialize(origin:, baseurl:)
14
+ @origin = normalize_origin(origin)
15
+ @baseurl = normalize_baseurl(baseurl)
16
+ freeze
17
+ end
18
+
19
+ def route_for_note(path, directory_index: false)
20
+ normalized = path.unicode_normalize(:nfc)
21
+ segments = normalized.split("/")
22
+ filename = segments.pop
23
+ stem = filename.delete_suffix(".md")
24
+ segments << stem unless directory_index || stem.casecmp("index").zero?
25
+ encoded = segments.reject(&:empty?).map { |segment| encode_segment(segment) }
26
+ encoded.empty? ? "/" : "/#{encoded.join("/")}/"
27
+ end
28
+
29
+ def validate_permalink(value)
30
+ return nil unless value.is_a?(String)
31
+ return nil unless value.start_with?("/") && value.end_with?("/")
32
+
33
+ normalized = decode_safe_path(value).unicode_normalize(:nfc)
34
+ return nil if normalized.match?(INVALID_PERMALINK)
35
+ return nil if normalized.include?("//")
36
+
37
+ segments = normalized.split("/").reject(&:empty?)
38
+ return "/" if segments.empty?
39
+
40
+ "/#{segments.map { |segment| encode_segment(segment) }.join("/")}/"
41
+ rescue ArgumentError, EncodingError, URI::InvalidURIError
42
+ nil
43
+ end
44
+
45
+ def validate_file_route(value, extension:)
46
+ return nil unless value.is_a?(String)
47
+ return nil unless value.start_with?("/") && !value.end_with?("/")
48
+
49
+ normalized = decode_safe_path(value).unicode_normalize(:nfc)
50
+ return nil if normalized.match?(INVALID_PERMALINK)
51
+ return nil if normalized.include?("//")
52
+ return nil unless File.extname(normalized) == extension
53
+
54
+ segments = normalized.split("/").reject(&:empty?)
55
+ return nil if segments.empty?
56
+
57
+ "/#{segments.map { |segment| encode_segment(segment) }.join('/')}"
58
+ rescue ArgumentError, EncodingError, URI::InvalidURIError
59
+ nil
60
+ end
61
+
62
+ def href(route)
63
+ return route if baseurl.empty?
64
+ return "#{baseurl}/" if route == "/"
65
+
66
+ "#{baseurl}#{route}"
67
+ end
68
+
69
+ def absolute_url(route)
70
+ return nil if origin.empty?
71
+
72
+ "#{origin}#{href(route)}"
73
+ end
74
+
75
+ def attachment_route(path)
76
+ encoded = path.unicode_normalize(:nfc).split("/").map { |segment| encode_segment(segment) }
77
+ "/assets/vault/#{encoded.join("/")}"
78
+ end
79
+
80
+ def fragment(value)
81
+ return "" if value.nil? || value.empty?
82
+
83
+ anchor = value.start_with?("^") ? value.delete_prefix("^") : slug(value)
84
+ "##{encode_fragment(anchor)}"
85
+ end
86
+
87
+ def slug(value)
88
+ slug = value.to_s.unicode_normalize(:nfc).downcase
89
+ .gsub(/<[^>]+>/, "")
90
+ .gsub(/[^\p{L}\p{N}\-_ ]+/u, "")
91
+ .strip
92
+ .gsub(/[ _]+/, "-")
93
+ slug.empty? ? "section" : slug
94
+ end
95
+
96
+ def collision_key(route)
97
+ raw = route.to_s
98
+ raise ArgumentError, "route cannot contain a query or fragment" if raw.match?(/[?#]/)
99
+
100
+ decoded = decode_safe_path(raw).unicode_normalize(:nfc)
101
+ raise ArgumentError, "route must be a root-relative path" unless decoded.start_with?("/")
102
+ raise ArgumentError, "route cannot contain an empty path segment" if decoded.include?("//")
103
+
104
+ decoded = decoded.downcase(:fold)
105
+ decoded = "#{decoded}/" unless decoded.end_with?("/") || File.extname(decoded) != ""
106
+ decoded
107
+ end
108
+
109
+ private
110
+
111
+ def normalize_origin(value)
112
+ text = value.to_s.strip.sub(%r{/+\z}, "")
113
+ return "" if text.empty?
114
+
115
+ uri = URI.parse(text)
116
+ raise ArgumentError, "url must be an http(s) origin" unless %w[http https].include?(uri.scheme) && uri.host
117
+ raise ArgumentError, "url must not include a path, query, or fragment" unless [nil, "", "/"].include?(uri.path) && uri.query.nil? && uri.fragment.nil?
118
+
119
+ default_port = (uri.scheme == "http" && uri.port == 80) || (uri.scheme == "https" && uri.port == 443)
120
+ "#{uri.scheme}://#{uri.host}#{default_port ? "" : ":#{uri.port}"}"
121
+ end
122
+
123
+ def normalize_baseurl(value)
124
+ text = value.to_s
125
+ return "" if text.empty? || text == "/"
126
+ raise ArgumentError, "baseurl must start with one slash" unless text.start_with?("/")
127
+ raise ArgumentError, "baseurl cannot have a trailing slash" if text.end_with?("/")
128
+
129
+ decoded = decode_safe_path(text).unicode_normalize(:nfc)
130
+ if decoded.include?("//") || decoded.match?(/[?#]/)
131
+ raise ArgumentError, "baseurl cannot contain query, fragment, traversal, or empty path segments"
132
+ end
133
+
134
+ "/#{decoded.split("/").reject(&:empty?).map { |segment| encode_segment(segment) }.join("/")}"
135
+ end
136
+
137
+ def encode_segment(segment)
138
+ URI.encode_uri_component(segment.unicode_normalize(:nfc))
139
+ end
140
+
141
+ def encode_fragment(fragment)
142
+ URI.encode_uri_component(fragment).gsub("%2F", "/")
143
+ end
144
+
145
+ def percent_decode(value)
146
+ URI.decode_uri_component(value)
147
+ end
148
+
149
+ def decode_safe_path(value)
150
+ text = value.to_s
151
+ raise ArgumentError, "path must be valid UTF-8" unless text.valid_encoding?
152
+ raise ArgumentError, "encoded path separators are not allowed" if text.match?(ENCODED_SEPARATOR)
153
+
154
+ decoded = percent_decode(text)
155
+ raise ArgumentError, "path must be valid UTF-8" unless decoded.valid_encoding?
156
+ raise ArgumentError, "control characters are not allowed in paths" if decoded.match?(CONTROL_CHARACTER)
157
+ raise ArgumentError, "backslashes are not allowed in paths" if decoded.include?("\\")
158
+ raise ArgumentError, "dot segments are not allowed in paths" if decoded.split("/", -1).any? { |segment| segment == "." || segment == ".." }
159
+
160
+ decoded
161
+ end
162
+ end
163
+ end