jekyll-obsidian-site 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +133 -0
- data/website/_config.yml +21 -0
- data/website/_includes/website/archive-timeline.html +8 -0
- data/website/_includes/website/blog-ledger.html +19 -0
- data/website/_includes/website/blog-navigation.html +7 -0
- data/website/_includes/website/comments.html +22 -0
- data/website/_includes/website/contact-icon.html +14 -0
- data/website/_includes/website/context-tags.html +7 -0
- data/website/_includes/website/context.html +3 -0
- data/website/_includes/website/dialogs.html +71 -0
- data/website/_includes/website/docs-navigation.html +7 -0
- data/website/_includes/website/docs-tree.html +8 -0
- data/website/_includes/website/footer.html +14 -0
- data/website/_includes/website/github-icon.html +3 -0
- data/website/_includes/website/head.html +41 -0
- data/website/_includes/website/header.html +35 -0
- data/website/_includes/website/language-switcher.html +16 -0
- data/website/_includes/website/local-graph.html +25 -0
- data/website/_includes/website/note-header.html +35 -0
- data/website/_includes/website/outline.html +9 -0
- data/website/_includes/website/page-actions.html +34 -0
- data/website/_includes/website/portfolio-grid.html +36 -0
- data/website/_includes/website/portfolio-topics.html +4 -0
- data/website/_includes/website/post-cards.html +16 -0
- data/website/_includes/website/related-articles.html +7 -0
- data/website/_includes/website/relations.html +16 -0
- data/website/_includes/website/source-actions.html +28 -0
- data/website/_includes/website/system-page.html +31 -0
- data/website/_includes/website/tab-collection.html +28 -0
- data/website/_includes/website/tag-filter.html +12 -0
- data/website/_layouts/website-docs.html +20 -0
- data/website/_layouts/website-minimal.html +93 -0
- data/website/_layouts/website-redirect.html +17 -0
- data/website/assets/chunks/abnfDiagram-N423BO3Z-TSORAEI2.js +1 -0
- data/website/assets/chunks/architecture-TIHT7OUA-OUC5PEC7.js +1 -0
- data/website/assets/chunks/architectureDiagram-T3A2C74G-6P5MB2DJ.js +36 -0
- data/website/assets/chunks/blockDiagram-VBNYF7ZC-ZWINYROZ.js +132 -0
- data/website/assets/chunks/c4Diagram-5PPSVZJV-R5RMEAOT.js +10 -0
- data/website/assets/chunks/chunk-22DRK7VS.js +1 -0
- data/website/assets/chunks/chunk-25REBZ7M.js +2 -0
- data/website/assets/chunks/chunk-2RQCUDTG.js +1 -0
- data/website/assets/chunks/chunk-34FHPXU4.js +1 -0
- data/website/assets/chunks/chunk-57XXJOQN.js +10 -0
- data/website/assets/chunks/chunk-6DCNDVBX.js +2 -0
- data/website/assets/chunks/chunk-6DHFEWGX.js +1 -0
- data/website/assets/chunks/chunk-6VQ47JJS.js +2 -0
- data/website/assets/chunks/chunk-7CSIRG7W.js +127 -0
- data/website/assets/chunks/chunk-A43YELH5.js +1 -0
- data/website/assets/chunks/chunk-AIX6IH4O.js +1 -0
- data/website/assets/chunks/chunk-B3K7RH5O.js +88 -0
- data/website/assets/chunks/chunk-CBGUD2NP.js +1 -0
- data/website/assets/chunks/chunk-D3WOCELE.js +1 -0
- data/website/assets/chunks/chunk-D6FURU6X.js +1 -0
- data/website/assets/chunks/chunk-DGEGT77F.js +1 -0
- data/website/assets/chunks/chunk-DN52R6U7.js +1 -0
- data/website/assets/chunks/chunk-E3T3IFAC.js +1 -0
- data/website/assets/chunks/chunk-EIBFAW37.js +1 -0
- data/website/assets/chunks/chunk-ERWOH47R.js +1 -0
- data/website/assets/chunks/chunk-ETOVTYWF.js +1 -0
- data/website/assets/chunks/chunk-GHD3PFWA.js +231 -0
- data/website/assets/chunks/chunk-GMBKQ7NZ.js +1 -0
- data/website/assets/chunks/chunk-GQM25EPP.js +156 -0
- data/website/assets/chunks/chunk-GSOU4UTT.js +1 -0
- data/website/assets/chunks/chunk-HEMELP7J.js +1 -0
- data/website/assets/chunks/chunk-HZ5I5NJO.js +1 -0
- data/website/assets/chunks/chunk-IP56FOSI.js +70 -0
- data/website/assets/chunks/chunk-L7BMR7WL.js +1 -0
- data/website/assets/chunks/chunk-LGUG3SQB.js +1 -0
- data/website/assets/chunks/chunk-LOMHZYS2.js +1 -0
- data/website/assets/chunks/chunk-MII4VZEB.js +3 -0
- data/website/assets/chunks/chunk-MYMJLHQL.js +62 -0
- data/website/assets/chunks/chunk-NBH7YOBD.js +15 -0
- data/website/assets/chunks/chunk-NCOVM3YF.js +1 -0
- data/website/assets/chunks/chunk-OAGTSILX.js +1 -0
- data/website/assets/chunks/chunk-OCX5OCIU.js +321 -0
- data/website/assets/chunks/chunk-OE3NOVIH.js +1 -0
- data/website/assets/chunks/chunk-PLP5XUTJ.js +1 -0
- data/website/assets/chunks/chunk-PNOERP6G.js +1 -0
- data/website/assets/chunks/chunk-QBPQ7DPE.js +1 -0
- data/website/assets/chunks/chunk-RWFFDL53.js +1 -0
- data/website/assets/chunks/chunk-SEGIW3MP.js +1 -0
- data/website/assets/chunks/chunk-SZDMZNUS.js +1 -0
- data/website/assets/chunks/chunk-T2QBAPVT.js +1 -0
- data/website/assets/chunks/chunk-UE533OZ7.js +1 -0
- data/website/assets/chunks/chunk-UTWOJE2A.js +1 -0
- data/website/assets/chunks/chunk-VOCDYA3H.js +1 -0
- data/website/assets/chunks/chunk-VYZZPKGK.js +161 -0
- data/website/assets/chunks/chunk-WWLZIRS6.js +1 -0
- data/website/assets/chunks/chunk-XNXYDBKX.js +32 -0
- data/website/assets/chunks/chunk-XQUIO562.js +1 -0
- data/website/assets/chunks/chunk-Y2XVFW7T.js +2 -0
- data/website/assets/chunks/chunk-YORIST7Z.js +1 -0
- data/website/assets/chunks/chunk-Z2WBQPVY.js +206 -0
- data/website/assets/chunks/classDiagram-JCYQIIEL-OFDVDDIM.js +1 -0
- data/website/assets/chunks/classDiagram-v2-OCEON4UE-E36B6BGQ.js +1 -0
- data/website/assets/chunks/cose-bilkent-JH36ORCC-CBAW64AB.js +1 -0
- data/website/assets/chunks/cynefin-VYW2F7L2-CBO6DLNV.js +1 -0
- data/website/assets/chunks/cynefinDiagram-MW4NZA55-DFINLOUY.js +62 -0
- data/website/assets/chunks/dagre-VZM6K2ZE-BZFBSZAH.js +4 -0
- data/website/assets/chunks/diagram-7IWD3JNH-XQHTVOU2.js +30 -0
- data/website/assets/chunks/diagram-B4RE2ZJO-FTSXBZJR.js +3 -0
- data/website/assets/chunks/diagram-LBJQPF4R-ZSZW4OLH.js +24 -0
- data/website/assets/chunks/diagram-Q27KOJAE-2IV5TVCJ.js +24 -0
- data/website/assets/chunks/diagram-UB23O5K3-D6PDUTFL.js +41 -0
- data/website/assets/chunks/ebnfDiagram-BXEA7PRR-C6MAER3M.js +1 -0
- data/website/assets/chunks/erDiagram-JOGREHBK-EDAD2H5M.js +85 -0
- data/website/assets/chunks/eventmodeling-45OFAUF4-4K577DMF.js +1 -0
- data/website/assets/chunks/flowDiagram-UKHOOZJN-545F4YX3.js +1 -0
- data/website/assets/chunks/ganttDiagram-PKOTCBZU-7A7EKMY3.js +292 -0
- data/website/assets/chunks/gitGraph-TEB2WS4Q-3WUB7WYQ.js +1 -0
- data/website/assets/chunks/gitGraphDiagram-DS77QQ5N-A5UWV5FF.js +106 -0
- data/website/assets/chunks/info-DKCQHKI2-EOOHI7XS.js +1 -0
- data/website/assets/chunks/infoDiagram-6WML65LV-CWIOOWJP.js +2 -0
- data/website/assets/chunks/ishikawaDiagram-WSZJBQD7-CEASQVDL.js +70 -0
- data/website/assets/chunks/journeyDiagram-NVQOT4AX-LYG3S2AW.js +139 -0
- data/website/assets/chunks/kanban-definition-27J2QSJJ-HCC6KAYI.js +89 -0
- data/website/assets/chunks/katex-F4MQ6FB3.js +257 -0
- data/website/assets/chunks/mindmap-definition-FAOFIHXS-JMGSM3RE.js +96 -0
- data/website/assets/chunks/packet-7NZHBO7P-W3SJA3MY.js +1 -0
- data/website/assets/chunks/pegDiagram-VL7TDLO6-4JO3Y3Z3.js +1 -0
- data/website/assets/chunks/pie-RZYD4A2V-YOIQBTY7.js +1 -0
- data/website/assets/chunks/pieDiagram-7S7Q4E2Y-VFQ7SE46.js +39 -0
- data/website/assets/chunks/quadrantDiagram-CIZ2JOQS-JISDI2S6.js +7 -0
- data/website/assets/chunks/radar-I7S5WNFK-NMKP5AZO.js +1 -0
- data/website/assets/chunks/railroad-3IZDKUUU-7MOIV7CI.js +1 -0
- data/website/assets/chunks/railroad-abnf-AHOZXSZD-HXNH5SLO.js +1 -0
- data/website/assets/chunks/railroad-ebnf-EBAXGLYW-OWDJEWMK.js +1 -0
- data/website/assets/chunks/railroad-peg-LSFZ7HO6-ASSINGP5.js +1 -0
- data/website/assets/chunks/railroadDiagram-AXF67PYL-AFV5VP5F.js +1 -0
- data/website/assets/chunks/requirementDiagram-LRYGKXZP-RNRPEHHN.js +84 -0
- data/website/assets/chunks/sankeyDiagram-W5VNT64P-O54R2L3E.js +40 -0
- data/website/assets/chunks/sequenceDiagram-SI44F4Z6-42WISKJ7.js +162 -0
- data/website/assets/chunks/sizeCapture-X5ZJPWSS-LRN2Q37T.js +1 -0
- data/website/assets/chunks/stateDiagram-OKZ733FA-BWIBJXIR.js +1 -0
- data/website/assets/chunks/stateDiagram-v2-UEYNNEHI-L73LHL3L.js +1 -0
- data/website/assets/chunks/swimlanes-SLNWSIFB-ECOAE7LQ.js +1 -0
- data/website/assets/chunks/swimlanesDiagram-ULZ7WXOC-3CKKKFZZ.js +8 -0
- data/website/assets/chunks/timeline-definition-Z64GVDOM-HR2OL5G7.js +120 -0
- data/website/assets/chunks/treeView-QDETBFTQ-C6D5WFJA.js +1 -0
- data/website/assets/chunks/treemap-6X3UGDF4-JFCDMSCA.js +1 -0
- data/website/assets/chunks/vennDiagram-T6HMQDX7-62WAAPTF.js +34 -0
- data/website/assets/chunks/wardley-OPB4EBWU-RQIOII7I.js +1 -0
- data/website/assets/chunks/wardleyDiagram-T6FBY63Y-KUXO3NHV.js +78 -0
- data/website/assets/chunks/xychartDiagram-ELKLHX3M-I54W4HJ7.js +7 -0
- data/website/assets/color-scheme-bootstrap-4H4ZR4BW.js +1 -0
- data/website/assets/docs-3WBBQT6K.css +1 -0
- data/website/assets/docs-W3D5DWQF.js +1 -0
- data/website/assets/docs-navigation-75ALFYBA.js +1 -0
- data/website/assets/graph-QWL54LWD.js +1 -0
- data/website/assets/manifest.json +314 -0
- data/website/assets/math-7JTH4OWZ.js +21 -0
- data/website/assets/mermaid-RXC6URG4.js +10 -0
- data/website/assets/minimal-3NPTDWQX.js +1 -0
- data/website/assets/minimal-E2VATXXS.css +1 -0
- data/website/assets/previews-FMHOX7UX.js +1 -0
- data/website/assets/search-BH73JZDU.js +1 -0
- data/website/assets/search-worker-SM4IM4J7.js +1 -0
- data/website/exe/jekyll-obsidian +5 -0
- data/website/lib/jekyll_obsidian/adapter.rb +733 -0
- data/website/lib/jekyll_obsidian/built_in_themes.rb +855 -0
- data/website/lib/jekyll_obsidian/cli.rb +84 -0
- data/website/lib/jekyll_obsidian/content_policy.rb +194 -0
- data/website/lib/jekyll_obsidian/destination_registry.rb +39 -0
- data/website/lib/jekyll_obsidian/directory_indexes.rb +30 -0
- data/website/lib/jekyll_obsidian/external_media.rb +371 -0
- data/website/lib/jekyll_obsidian/front_matter.rb +453 -0
- data/website/lib/jekyll_obsidian/github_markdown.rb +664 -0
- data/website/lib/jekyll_obsidian/html_publication.rb +206 -0
- data/website/lib/jekyll_obsidian/initializer.rb +68 -0
- data/website/lib/jekyll_obsidian/localized_compiler.rb +1044 -0
- data/website/lib/jekyll_obsidian/media_policy.rb +41 -0
- data/website/lib/jekyll_obsidian/ofm_scanner.rb +493 -0
- data/website/lib/jekyll_obsidian/output_text.rb +13 -0
- data/website/lib/jekyll_obsidian/preview.rb +93 -0
- data/website/lib/jekyll_obsidian/published_markdown.rb +32 -0
- data/website/lib/jekyll_obsidian/raw_html_manifest.rb +66 -0
- data/website/lib/jekyll_obsidian/runtime.rb +197 -0
- data/website/lib/jekyll_obsidian/site_compilation.rb +147 -0
- data/website/lib/jekyll_obsidian/site_navigation.rb +517 -0
- data/website/lib/jekyll_obsidian/url_builder.rb +163 -0
- data/website/lib/jekyll_obsidian/value_objects.rb +198 -0
- data/website/lib/jekyll_obsidian/vault_compiler.rb +2771 -0
- data/website/lib/jekyll_obsidian/version.rb +6 -0
- data/website/lib/jekyll_obsidian/workspace_layout.rb +104 -0
- data/website/lib/jekyll_obsidian.rb +22 -0
- data/website/scripts/audit-site.rb +120 -0
- data/website/scripts/templates/pages.yml +55 -0
- data/website/scripts/verify-site-urls.rb +585 -0
- metadata +319 -0
|
@@ -0,0 +1,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
|