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,855 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi/escape"
4
+ require "date"
5
+
6
+ module JekyllObsidian
7
+ # The only internal theme seam. Each built-in adapter consumes the same
8
+ # immutable PublishedSiteModel and returns a complete ThemeOutput through a
9
+ # single render interface.
10
+ module BuiltInThemes
11
+ IDS = %w[minimal docs].freeze
12
+ ALWAYS_RESERVED_NAMESPACES = %w[
13
+ /404.html /sitemap.xml /assets/website /assets/vault
14
+ ].freeze
15
+ module_function
16
+
17
+ def resolve(id)
18
+ case id
19
+ when "minimal" then Minimal.new
20
+ when "docs" then Docs.new
21
+ else
22
+ raise ArgumentError, "unknown built-in theme #{id.inspect}"
23
+ end
24
+ end
25
+
26
+ class Presenter
27
+ private
28
+
29
+ def project(model:, config:, landing_note:, note_theme_data:, theme_pages:, system_theme_data:, taxonomy:, feed_notes:, shared_files: [], suppressed_note_routes: [], &note_page_transform)
30
+ topic_anchors = taxonomy.fetch("anchors")
31
+ tag_groups = taxonomy.fetch("groups")
32
+ topic_summaries = taxonomy.fetch("summaries")
33
+ local_graphs = config.features.fetch("graph") ? local_graphs(model, config) : {}
34
+ home_route = landing_note ? landing_note.route : "/"
35
+ home_url = config.url_builder.href(home_route)
36
+ pages = model.notes.reject { |note| suppressed_note_routes.include?(note.route) }.map do |note|
37
+ output = note_page(
38
+ note,
39
+ config,
40
+ note_theme_data.fetch(note.id),
41
+ topic_anchors,
42
+ topic_summaries,
43
+ local_graphs[note.id],
44
+ home_route,
45
+ home_url
46
+ )
47
+ note_page_transform ? note_page_transform.call(note, output) : output
48
+ end
49
+ pages.concat(theme_pages)
50
+ if config.theme == "docs" && config.features.fetch("tags")
51
+ pages << tags_page(tag_groups, config, system_theme_data, home_route, home_url)
52
+ end
53
+ pages << not_found_page(config, system_theme_data, home_route, home_url)
54
+ pages << redirect_page(landing_note, config, home_route, home_url) if landing_note && pages.none? { |page| page.route == "/" }
55
+
56
+ artifacts = []
57
+ artifacts << "catalog" if config.features.fetch("previews")
58
+ artifacts << "graph" if config.features.fetch("graph")
59
+ artifacts << "search" if config.features.fetch("search")
60
+ artifacts << "sitemap"
61
+ artifacts << "feed" if config.features.fetch("feed")
62
+
63
+ namespaces = ALWAYS_RESERVED_NAMESPACES.dup
64
+ namespaces << "/feed.xml" if config.features.fetch("feed")
65
+ pages = pages.map { |page| attach_navigation(page, config) }
66
+
67
+ ThemeOutput.new(
68
+ pages: pages,
69
+ artifacts: artifacts,
70
+ shared_files: shared_files,
71
+ site_data: {},
72
+ feed_note_ids: feed_notes.sort_by(&:id).map(&:id),
73
+ reserved_namespaces: namespaces
74
+ )
75
+ end
76
+
77
+ def note_page(note, config, theme_data, topic_anchors, topic_summaries, local_graph, home_route, home_url)
78
+ properties = note.properties
79
+ comments = page_comments(note, config)
80
+ website = {
81
+ "kind" => "note",
82
+ "id" => note.id,
83
+ "content_type" => note.content_type,
84
+ "published_at" => note.published_at,
85
+ "route" => note.route,
86
+ "href" => config.url_builder.href(note.route),
87
+ "absolute_url" => config.url_builder.absolute_url(note.route),
88
+ "markdown_url" => config.url_builder.href(PublishedMarkdown.route(note.route)),
89
+ "aliases" => Array(properties["aliases"]),
90
+ "subtitle" => properties["subtitle"],
91
+ "tags" => Array(properties["tags"]),
92
+ "authors" => note.topics.select { |topic| topic.fetch("kind") == "author" },
93
+ "categories" => Array(properties["categories"]),
94
+ "topic_links" => blog_topic_links(note, topic_anchors),
95
+ "cssclasses" => Array(properties["cssclasses"]),
96
+ "created" => note.created,
97
+ "updated" => note.updated,
98
+ "directory_index" => note.directory_index,
99
+ "has_h1" => note.has_h1,
100
+ "source_links" => note.source_links,
101
+ "home_route" => home_route,
102
+ "home_url" => home_url,
103
+ "routes" => { "home" => home_route },
104
+ "theme" => config.theme,
105
+ "features" => config.features.merge(note.feature_flags),
106
+ "content_security" => content_security_data(note.content_security, config),
107
+ "theme_data" => theme_data,
108
+ "tag_links" => Array(note.properties["tags"]).filter_map do |tag|
109
+ anchor = topic_anchors[topic_identity("name" => tag)]
110
+ { "name" => tag, "anchor" => anchor } if anchor
111
+ end,
112
+ "outline" => note.outline,
113
+ "related_articles" => theme_data.fetch("related_articles", note.related || []),
114
+ "links" => config.features.fetch("relations") ? note.links : [],
115
+ "backlinks" => config.features.fetch("relations") ? note.backlinks : [],
116
+ "embedded_by" => config.features.fetch("relations") ? note.embedded_by : []
117
+ }
118
+ website["local_graph"] = local_graph if local_graph
119
+ website["has_context"] = context_present?(website)
120
+ website["comments"] = comments if comments
121
+ website["analytics"] = analytics_data(config.analytics) unless config.analytics.provider.empty?
122
+ data = {
123
+ "title" => note.title,
124
+ "description" => properties["description"] || note.preview,
125
+ "image" => note.image_url,
126
+ "layout" => "website-#{config.theme}",
127
+ "website" => website
128
+ }.compact
129
+ PageOutput.new(route: note.route, content: note.content, data: data)
130
+ end
131
+
132
+ def system_page(
133
+ config,
134
+ route,
135
+ title,
136
+ kind,
137
+ system_theme_data,
138
+ theme_data = {},
139
+ home_route: "/",
140
+ home_url: config.url_builder.href(home_route),
141
+ content: "",
142
+ description: config.site.description.to_s,
143
+ website_data: {}
144
+ )
145
+ PageOutput.new(
146
+ route: route,
147
+ content: content,
148
+ data: {
149
+ "layout" => "website-#{config.theme}",
150
+ "title" => title,
151
+ "description" => description,
152
+ "website" => {
153
+ "kind" => kind,
154
+ "theme" => config.theme,
155
+ "features" => config.features,
156
+ "content_security" => content_security_data(nil, config),
157
+ "theme_data" => system_theme_data.merge(theme_data),
158
+ "route" => route,
159
+ "href" => config.url_builder.href(route),
160
+ "absolute_url" => config.url_builder.absolute_url(route),
161
+ "home_route" => home_route,
162
+ "home_url" => home_url,
163
+ "routes" => { "home" => home_route }
164
+ }.merge(website_data).tap do |website|
165
+ website["analytics"] = analytics_data(config.analytics) unless config.analytics.provider.empty?
166
+ end
167
+ }
168
+ )
169
+ end
170
+
171
+ def tags_page(tag_groups, config, system_theme_data, home_route, home_url)
172
+ system_page(
173
+ config,
174
+ "/tags/",
175
+ "Tags",
176
+ "tags",
177
+ system_theme_data,
178
+ { "tag_groups" => tag_groups },
179
+ home_route: home_route,
180
+ home_url: home_url
181
+ )
182
+ end
183
+
184
+ def analytics_data(analytics)
185
+ {
186
+ "provider" => analytics.provider,
187
+ "identifier" => analytics.identifier,
188
+ "load" => analytics.load
189
+ }
190
+ end
191
+
192
+ def content_security_data(content_security, config)
193
+ media_sources = Array(content_security&.media_sources).dup
194
+ frame_sources = Array(content_security&.frame_sources).dup
195
+ script_sources = Array(content_security&.script_sources).dup
196
+ connect_sources = Array(content_security&.connect_sources).dup
197
+ if config.analytics.load
198
+ case config.analytics.provider
199
+ when "cloudflare"
200
+ script_sources << "https://static.cloudflareinsights.com"
201
+ connect_sources << "https://cloudflareinsights.com"
202
+ when "google"
203
+ script_sources << "https://www.googletagmanager.com"
204
+ connect_sources.concat(%w[
205
+ https://*.analytics.google.com
206
+ https://*.google-analytics.com
207
+ https://www.googletagmanager.com
208
+ ])
209
+ end
210
+ end
211
+ {
212
+ "media_sources" => media_sources.uniq.sort,
213
+ "frame_sources" => frame_sources.uniq.sort,
214
+ "script_sources" => script_sources.uniq.sort,
215
+ "connect_sources" => connect_sources.uniq.sort
216
+ }
217
+ end
218
+
219
+ def build_tag_groups(notes, anchors, config)
220
+ groups = Hash.new { |hash, key| hash[key] = [] }
221
+ notes.each { |note| Array(note.properties["tags"]).each { |tag| groups[tag] << note } }
222
+ groups.keys.sort.map do |tag|
223
+ {
224
+ "name" => tag,
225
+ "anchor" => anchors.fetch(topic_identity("name" => tag)),
226
+ "notes" => groups.fetch(tag).sort_by(&:id).map { |note| system_note_card(note, config) }
227
+ }
228
+ end
229
+ end
230
+
231
+ def build_topic_groups(notes, anchors, config)
232
+ groups = {}
233
+ notes.each do |note|
234
+ topics_for(note, config).uniq { |topic| topic_identity(topic) }.each do |topic|
235
+ identity = topic_identity(topic)
236
+ groups[identity] ||= { "topic" => topic, "notes" => [] }
237
+ groups.fetch(identity).fetch("notes") << note
238
+ end
239
+ end
240
+
241
+ groups.map do |identity, group|
242
+ topic = group.fetch("topic")
243
+ {
244
+ "name" => topic.fetch("name"),
245
+ "anchor" => anchors.fetch(identity),
246
+ "url" => topic["url"],
247
+ "notes" => group.fetch("notes").sort_by(&:id).map { |note| system_note_card(note, config) }
248
+ }
249
+ end
250
+ end
251
+
252
+ def summarize_topics(topic_groups)
253
+ topic_groups.map do |group|
254
+ group.slice("name", "anchor", "url").merge("count" => group.fetch("notes").length).compact
255
+ end.sort_by { |topic| [-topic.fetch("count"), topic.fetch("name").downcase, topic.fetch("name")] }
256
+ end
257
+
258
+ def taxonomy_for(notes, config)
259
+ return { "anchors" => {}, "groups" => [], "summaries" => [] } unless config.features.fetch("tags")
260
+
261
+ anchors = topic_anchor_map(notes, config)
262
+ groups = build_topic_groups(notes, anchors, config)
263
+ {
264
+ "anchors" => anchors,
265
+ "groups" => build_tag_groups(notes, anchors, config),
266
+ "summaries" => summarize_topics(groups)
267
+ }
268
+ end
269
+
270
+ def timeline_for(notes)
271
+ month_counts = Hash.new(0)
272
+ notes.each do |note|
273
+ month = month_key(yield(note))
274
+ month_counts[month] += 1 if month
275
+ end
276
+
277
+ month_counts.keys.group_by { |month| month[0, 4] }.sort.reverse.map do |year, months|
278
+ {
279
+ "year" => year,
280
+ "count" => months.sum { |month| month_counts.fetch(month) },
281
+ "months" => months.sort.map do |month|
282
+ { "key" => month, "label" => month[5, 2], "count" => month_counts.fetch(month) }
283
+ end
284
+ }
285
+ end
286
+ end
287
+
288
+ def month_key(value)
289
+ match = value.to_s.match(/\A(\d{4})-(0[1-9]|1[0-2])/)
290
+ match && "#{match[1]}-#{match[2]}"
291
+ end
292
+
293
+ def redirect_page(landing_note, config, home_route, home_url)
294
+ PageOutput.new(
295
+ route: "/",
296
+ content: "",
297
+ data: {
298
+ "layout" => "website-redirect",
299
+ "title" => landing_note.title,
300
+ "description" => landing_note.preview,
301
+ "website" => {
302
+ "kind" => "redirect",
303
+ "theme" => config.theme,
304
+ "features" => config.features,
305
+ "route" => "/",
306
+ "href" => config.url_builder.href("/"),
307
+ "absolute_url" => config.url_builder.absolute_url("/"),
308
+ "canonical_url" => config.url_builder.absolute_url(landing_note.route),
309
+ "redirect_url" => home_url,
310
+ "home_route" => home_route,
311
+ "home_url" => home_url,
312
+ "routes" => { "home" => home_route },
313
+ "robots" => "noindex"
314
+ }
315
+ }
316
+ )
317
+ end
318
+
319
+ def local_graphs(model, config)
320
+ edges_by_note = Hash.new { |hash, id| hash[id] = [] }
321
+ neighbours_by_note = Hash.new { |hash, id| hash[id] = {} }
322
+ model.graph_edges.each do |edge|
323
+ source = edge.fetch("source")
324
+ target = edge.fetch("target")
325
+ edges_by_note[source] << edge
326
+ edges_by_note[target] << edge unless source == target
327
+ neighbours_by_note[source][target] = true
328
+ neighbours_by_note[target][source] = true
329
+ end
330
+
331
+ model.notes.each_with_object({}) do |note, graphs|
332
+ neighbour_ids = neighbours_by_note[note.id].keys.reject { |id| id == note.id }
333
+ next if neighbour_ids.empty?
334
+
335
+ node_ids = [note.id, *neighbour_ids].sort
336
+ nodes = node_ids.map do |id|
337
+ target = model.notes_by_id.fetch(id)
338
+ {
339
+ "id" => id,
340
+ "title" => target.title,
341
+ "url" => config.url_builder.href(target.route),
342
+ "degree" => model.graph_degrees.fetch(id)
343
+ }
344
+ end
345
+ edges = edges_by_note[note.id].sort_by do |edge|
346
+ [edge.fetch("source"), edge.fetch("target"), edge.fetch("kind")]
347
+ end
348
+ graphs[note.id] = { "current_id" => note.id, "nodes" => nodes, "edges" => edges }
349
+ end
350
+ end
351
+
352
+ def context_present?(website)
353
+ return true if website["local_graph"]
354
+ return true if website.fetch("features").fetch("outline") && !website.fetch("outline").empty?
355
+ return true unless Array(website["tag_summaries"]).empty?
356
+
357
+ website.fetch("features").fetch("relations") &&
358
+ %w[links backlinks embedded_by].any? { |key| !website.fetch(key).empty? }
359
+ end
360
+
361
+ def page_comments(note, config)
362
+ comments = config.comments
363
+ return unless comments.enabled && note.content_type == "post"
364
+ return if note.properties["comments"] == false
365
+
366
+ {
367
+ "configured" => comments.configured,
368
+ "repository" => comments.repository,
369
+ "repository_id" => comments.repository_id,
370
+ "category" => comments.category,
371
+ "category_id" => comments.category_id,
372
+ "term" => "website:post:#{note.id.delete_suffix('.md')}",
373
+ "language" => comments.language,
374
+ "load" => comments.load,
375
+ "repository_url" => "https://github.com/#{comments.repository}",
376
+ "discussion_url" => "https://github.com/#{comments.repository}/discussions"
377
+ }
378
+ end
379
+
380
+ def not_found_page(config, system_theme_data, home_route, home_url)
381
+ system_page(
382
+ config,
383
+ "/404.html",
384
+ "Page not found",
385
+ "404",
386
+ system_theme_data,
387
+ { "home_url" => home_url },
388
+ home_route: home_route,
389
+ home_url: home_url
390
+ )
391
+ end
392
+
393
+ def system_note_card(note, config)
394
+ { "id" => note.id, "title" => note.title, "url" => config.url_builder.href(note.route) }
395
+ end
396
+
397
+ def topic_anchors_for(note, taxonomy, config)
398
+ anchors = taxonomy.fetch("anchors")
399
+ topics_for(note, config).filter_map { |topic| anchors[topic_identity(topic)] }.uniq
400
+ end
401
+
402
+ def topic_anchor_map(notes, config)
403
+ used = Hash.new(0)
404
+ topics = notes.flat_map { |note| topics_for(note, config) }.uniq { |topic| topic_identity(topic) }
405
+ topics.sort_by { |topic| [topic.fetch("name").downcase, topic.fetch("name"), topic["url"].to_s] }.to_h do |topic|
406
+ base = config.url_builder.slug(topic.fetch("name"))
407
+ used[base] += 1
408
+ [topic_identity(topic), used[base] == 1 ? base : "#{base}-#{used[base]}"]
409
+ end
410
+ end
411
+
412
+ def topics_for(note, config)
413
+ topics = note.topics
414
+ if config.theme == "minimal"
415
+ seen_names = {}
416
+ return topics.select do |topic|
417
+ next true unless %w[tag category].include?(topic.fetch("kind"))
418
+
419
+ name = topic_name_identity(topic)
420
+ next false if seen_names[name]
421
+
422
+ seen_names[name] = true
423
+ end
424
+ end
425
+
426
+ topics.select { |topic| topic.fetch("kind") == "tag" }
427
+ end
428
+
429
+ def blog_topic_links(note, topic_anchors)
430
+ note.topics
431
+ .select { |topic| %w[tag category].include?(topic.fetch("kind")) }
432
+ .uniq { |topic| topic_name_identity(topic) }
433
+ .filter_map do |topic|
434
+ anchor = topic_anchors[topic_identity(topic)]
435
+ { "name" => topic.fetch("name"), "anchor" => anchor } if anchor
436
+ end
437
+ end
438
+
439
+ def topic_name_identity(topic)
440
+ topic.fetch("name").unicode_normalize(:nfc).downcase(:fold)
441
+ end
442
+
443
+ def content_card(note, config)
444
+ {
445
+ "id" => note.id,
446
+ "title" => note.title,
447
+ "url" => config.url_builder.href(note.route),
448
+ "published_at" => note.published_at,
449
+ "summary" => note.properties["description"] || note.preview,
450
+ "subtitle" => note.properties["subtitle"],
451
+ "image" => note.image_url,
452
+ "authors" => note.topics.select { |topic| topic.fetch("kind") == "author" },
453
+ "tags" => Array(note.properties["tags"])
454
+ }
455
+ end
456
+
457
+ def tab_member_card(note, config)
458
+ topics = note.topics
459
+ .select { |topic| %w[tag category].include?(topic.fetch("kind")) }
460
+ .uniq { |topic| topic_name_identity(topic) }
461
+ .map { |topic| topic.slice("name", "url") }
462
+ content_card(note, config).merge(
463
+ "content_type" => note.content_type,
464
+ "topics" => topics
465
+ )
466
+ end
467
+
468
+ def attach_custom_tab_theme_data(theme_data, model, config)
469
+ config.navigation.custom_tabs.each do |tab|
470
+ cards = tab.member_note_ids.filter_map do |id|
471
+ note = model.notes_by_id[id]
472
+ tab_member_card(note, config) if note
473
+ end
474
+ theme_data[tab.index_note_id] = theme_data.fetch(tab.index_note_id).merge(
475
+ "tab_id" => tab.id,
476
+ "tab_member_ids" => tab.member_note_ids,
477
+ "tab_members" => cards
478
+ )
479
+ end
480
+ end
481
+
482
+ def related_article_cards(note, model, config)
483
+ Array(note.related).map do |related|
484
+ target = model.notes_by_id.fetch(related.fetch("id"))
485
+ content_card(target, config).merge(
486
+ "title" => related.fetch("title"),
487
+ "url" => related.fetch("url")
488
+ )
489
+ end
490
+ end
491
+
492
+ def topic_identity(topic)
493
+ [topic.fetch("name"), topic["url"].to_s]
494
+ end
495
+
496
+ def h(value)
497
+ text = value.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "\uFFFD")
498
+ CGI.escapeHTML(text.gsub(FrontMatter::XML_INVALID_CHARACTER, "\uFFFD"))
499
+ end
500
+
501
+ def attach_navigation(page, config)
502
+ navigation = config.navigation
503
+ website = page.data.fetch("website").dup
504
+ active_id = if website["kind"] == "note"
505
+ navigation.active_by_note_id[website["id"]]
506
+ else
507
+ navigation.items.find do |item|
508
+ item.fetch("active_scope").fetch("routes").include?(page.route)
509
+ end&.fetch("id")
510
+ end
511
+ website["navigation"] = navigation.items.map do |item|
512
+ item.slice("id", "label", "url", "order")
513
+ end
514
+ website["active_navigation_id"] = active_id
515
+ routes = navigation.items.to_h { |item| [item.fetch("id"), item.fetch("url")] }
516
+ routes["home"] ||= website.fetch("home_url")
517
+ routes["portfolio"] = config.url_builder.href(navigation.portfolio.route) if navigation.portfolio
518
+ routes["tags"] = config.url_builder.href("/tags/") if config.theme == "docs" && config.features.fetch("tags")
519
+ routes["feed"] = config.url_builder.href("/feed.xml") if config.features.fetch("feed")
520
+ website["routes"] = routes
521
+ data = page.data.merge("website" => website)
522
+ PageOutput.new(route: page.route, content: page.content, data: data)
523
+ end
524
+ end
525
+
526
+ class Minimal < Presenter
527
+ def render(model:, config:)
528
+ posts = ordered_posts(model)
529
+ displayed = displayed_posts(posts)
530
+ taxonomy = taxonomy_for(posts, config)
531
+ timeline = timeline_for(displayed, &:published_at)
532
+ documentation = config.navigation
533
+ portfolio = documentation.portfolio
534
+ portfolio_notes = portfolio ? portfolio.project_note_ids.map { |id| model.notes_by_id.fetch(id) } : []
535
+ portfolio_taxonomy = taxonomy_for(portfolio_notes, config)
536
+ portfolio_projects = portfolio_project_cards(portfolio_notes, config, portfolio_taxonomy)
537
+ portfolio_theme_data = {
538
+ "portfolio_projects" => portfolio_projects,
539
+ "portfolio_topic_summaries" => portfolio_taxonomy.fetch("summaries"),
540
+ "portfolio_topic_filter_count" => portfolio_projects.length
541
+ }
542
+ portfolio_projects_by_id = portfolio_projects.to_h { |project| [project.fetch("id"), project] }
543
+ linked_docs = documentation.docs_note_ids.filter_map { |id| model.notes_by_id[id] }
544
+ linked_doc_positions = linked_docs.each_with_index.to_h { |note, index| [note.id, index] }
545
+ post_positions = posts.each_with_index.to_h { |post, index| [post.id, index] }
546
+ theme_data = model.notes.to_h do |note|
547
+ post_index = post_positions[note.id]
548
+ doc_index = linked_doc_positions[note.id]
549
+ project = portfolio_projects_by_id[note.id]
550
+ data = {
551
+ "archive_groups" => [],
552
+ "docs_tree" => note.content_type == "doc" ? documentation.docs_tree : [],
553
+ "docs_home_url" => documentation.docs_home_url,
554
+ "related_articles" => related_article_cards(note, model, config),
555
+ "previous" => sequence_card(
556
+ note,
557
+ post_index,
558
+ doc_index,
559
+ posts,
560
+ linked_docs,
561
+ -1,
562
+ config,
563
+ taxonomy
564
+ ),
565
+ "next" => sequence_card(
566
+ note,
567
+ post_index,
568
+ doc_index,
569
+ posts,
570
+ linked_docs,
571
+ 1,
572
+ config,
573
+ taxonomy
574
+ )
575
+ }
576
+ data["portfolio_topics"] = project.fetch("topics") if project
577
+ [
578
+ note.id,
579
+ data
580
+ ]
581
+ end
582
+ system_theme_data = {
583
+ "archive_groups" => [],
584
+ "docs_tree" => documentation.docs_tree,
585
+ "docs_home_url" => documentation.docs_home_url,
586
+ "previous" => nil,
587
+ "next" => nil
588
+ }
589
+ root_path = model.directory_index_paths&.fetch(".", nil)
590
+ root = root_path && model.notes_by_id[root_path]
591
+ home_modules = home_theme_data(displayed, config, taxonomy)
592
+ theme_data[root.id] = theme_data.fetch(root.id).merge(home_modules) if root
593
+ if portfolio&.index_note_id
594
+ theme_data[portfolio.index_note_id] = theme_data.fetch(portfolio.index_note_id).merge(
595
+ portfolio_theme_data
596
+ )
597
+ end
598
+ attach_custom_tab_theme_data(theme_data, model, config)
599
+ authored_root_route = model.notes.any? { |note| note.route == "/" }
600
+ home = home_page(displayed, config, system_theme_data, taxonomy) if !root && !authored_root_route && displayed.any?
601
+ groups = archive_groups(displayed, config, taxonomy)
602
+ blog = if displayed.any?
603
+ system_page(
604
+ config,
605
+ "/blog/",
606
+ navigation_label(config, "blog", "Blog"),
607
+ "blog-index",
608
+ system_theme_data,
609
+ {
610
+ "archive_groups" => groups,
611
+ "topic_summaries" => taxonomy.fetch("summaries"),
612
+ "topic_filter_count" => displayed.length,
613
+ "timeline" => timeline
614
+ },
615
+ home_route: "/",
616
+ home_url: config.url_builder.href("/")
617
+ )
618
+ end
619
+ portfolio_page = if portfolio && !portfolio.index_note_id
620
+ system_page(
621
+ config,
622
+ portfolio.route,
623
+ navigation_label(config, "portfolio", "Portfolio"),
624
+ "portfolio-index",
625
+ system_theme_data,
626
+ portfolio_theme_data,
627
+ home_route: "/",
628
+ home_url: config.url_builder.href("/")
629
+ )
630
+ end
631
+ theme_pages = [home, blog, portfolio_page].compact
632
+ root_route_claimed = authored_root_route || theme_pages.any? { |page| page.route == "/" }
633
+ unless root || home || root_route_claimed
634
+ destination = config.navigation.items.first
635
+ if !destination && portfolio
636
+ destination = {
637
+ "id" => "portfolio",
638
+ "label" => navigation_label(config, "portfolio", "Portfolio"),
639
+ "url" => config.url_builder.href(portfolio.route)
640
+ }
641
+ end
642
+ theme_pages << redirect_to(destination, config) if destination
643
+ end
644
+ project(
645
+ model: model,
646
+ config: config,
647
+ landing_note: nil,
648
+ note_theme_data: theme_data,
649
+ theme_pages: theme_pages,
650
+ system_theme_data: system_theme_data,
651
+ taxonomy: taxonomy,
652
+ feed_notes: posts
653
+ ) do |note, output|
654
+ note.id == root&.id ? authored_home_page(output) : output
655
+ end
656
+ end
657
+
658
+ private
659
+
660
+ def ordered_posts(model)
661
+ dated, undated = model.notes.select { |note| note.content_type == "post" }.partition(&:published_at)
662
+ dated.sort_by! { |note| [chronology_key(note.published_at), note.id] }
663
+ undated.sort_by!(&:id)
664
+ dated + undated
665
+ end
666
+
667
+ def displayed_posts(posts)
668
+ dated, undated = posts.partition(&:published_at)
669
+ displayed = dated.reverse + undated
670
+ pinned, remaining = displayed.partition { |note| note.properties["pinned"] == true }
671
+ pinned + remaining
672
+ end
673
+
674
+ def archive_groups(posts, config, taxonomy)
675
+ posts.group_by { |note| note.published_at ? note.published_at[0, 4] : "Undated" }.map do |label, grouped|
676
+ { "label" => label, "posts" => grouped.map { |note| note_card(note, config, taxonomy) } }
677
+ end
678
+ end
679
+
680
+ def home_theme_data(posts, config, taxonomy)
681
+ {
682
+ "recent_posts" => posts.first(6).map { |post| note_card(post, config, taxonomy) },
683
+ "view_all_url" => config.url_builder.href("/blog/"),
684
+ "contacts" => config.contacts,
685
+ "topic_summaries" => taxonomy.fetch("summaries")
686
+ }
687
+ end
688
+
689
+ def home_page(posts, config, system_theme_data, taxonomy)
690
+ system_page(
691
+ config,
692
+ "/",
693
+ navigation_label(config, "home", "Home"),
694
+ "home",
695
+ system_theme_data,
696
+ home_theme_data(posts, config, taxonomy),
697
+ home_route: "/",
698
+ home_url: config.url_builder.href("/")
699
+ )
700
+ end
701
+
702
+ def authored_home_page(page)
703
+ website = page.data.fetch("website").merge("kind" => "home")
704
+ PageOutput.new(route: "/", content: page.content, data: page.data.merge("website" => website))
705
+ end
706
+
707
+ def redirect_to(item, config)
708
+ url = item.fetch("url")
709
+ PageOutput.new(
710
+ route: "/",
711
+ content: "",
712
+ data: {
713
+ "layout" => "website-redirect",
714
+ "title" => item.fetch("label"),
715
+ "description" => config.site.description.to_s,
716
+ "website" => {
717
+ "kind" => "redirect",
718
+ "redirect_navigation_id" => item.fetch("id"),
719
+ "theme" => config.theme,
720
+ "features" => config.features,
721
+ "route" => "/",
722
+ "href" => config.url_builder.href("/"),
723
+ "absolute_url" => config.url_builder.absolute_url("/"),
724
+ "canonical_url" => config.url_builder.origin.empty? ? nil : "#{config.url_builder.origin}#{url}",
725
+ "redirect_url" => url,
726
+ "home_route" => "/",
727
+ "home_url" => config.url_builder.href("/"),
728
+ "routes" => { "home" => "/" },
729
+ "robots" => "noindex"
730
+ }
731
+ }
732
+ )
733
+ end
734
+
735
+ def sequence_card(note, post_index, doc_index, posts, docs, offset, config, taxonomy)
736
+ collection, index = note.content_type == "post" ? [posts, post_index] : [docs, doc_index]
737
+ return unless index
738
+
739
+ target_index = index + offset
740
+ return if target_index.negative? || target_index >= collection.length
741
+
742
+ target = collection.fetch(target_index)
743
+ note.content_type == "post" ? note_card(target, config, taxonomy) : docs_card(target, config)
744
+ end
745
+
746
+ def docs_card(note, config)
747
+ {
748
+ "id" => note.id,
749
+ "title" => note.title,
750
+ "url" => !note.nav_exclude ? config.url_builder.href(note.route) : nil,
751
+ "content_type" => note.content_type
752
+ }
753
+ end
754
+
755
+ def navigation_label(config, id, fallback)
756
+ config.navigation.items.find { |item| item.fetch("id") == id }&.fetch("label") ||
757
+ config.site.navigation&.dig(id, "label") || fallback
758
+ end
759
+
760
+ def portfolio_project_cards(notes, config, taxonomy)
761
+ notes.map do |note|
762
+ topics = portfolio_topics_for(note, config, taxonomy)
763
+ card = {
764
+ "id" => note.id,
765
+ "title" => note.title,
766
+ "url" => config.url_builder.href(note.route),
767
+ "image" => note.image_url,
768
+ "summary" => note.properties["description"] || note.preview,
769
+ "topics" => topics,
770
+ "topic_anchors" => topics.map { |topic| topic.fetch("anchor") }
771
+ }
772
+ card["repository_url"] = note.source_links.fetch("repository") if note.source_links["repository"]
773
+ card
774
+ end
775
+ end
776
+
777
+ def portfolio_topics_for(note, config, taxonomy)
778
+ anchors = taxonomy.fetch("anchors")
779
+ topics_for(note, config).uniq { |topic| topic_identity(topic) }.filter_map do |topic|
780
+ anchor = anchors[topic_identity(topic)]
781
+ next unless anchor
782
+
783
+ topic.slice("name", "url").merge("anchor" => anchor).compact
784
+ end
785
+ end
786
+
787
+ def chronology_key(value)
788
+ [0, DateTime.iso8601(value.to_s).new_offset(0).ajd]
789
+ rescue Date::Error
790
+ [1, value.to_s]
791
+ end
792
+
793
+ def note_card(note, config, taxonomy)
794
+ content_card(note, config).merge(
795
+ "topics" => blog_topic_links(note, taxonomy.fetch("anchors")),
796
+ "topic_anchors" => topic_anchors_for(note, taxonomy, config),
797
+ "filter_month" => month_key(note.published_at)
798
+ )
799
+ end
800
+ end
801
+
802
+ class Docs < Presenter
803
+ def render(model:, config:)
804
+ taxonomy = taxonomy_for(model.notes, config)
805
+ navigation = config.navigation
806
+ docs_home_url = navigation.docs_home_url
807
+ linked = navigation.docs_note_ids.filter_map { |id| model.notes_by_id[id] }
808
+ root_path = model.directory_index_paths&.fetch(".", nil)
809
+ landing = (root_path && model.notes_by_id[root_path]) || linked.first || model.notes.first
810
+ linked_positions = linked.each_with_index.to_h { |linked_note, index| [linked_note.id, index] }
811
+ theme_data = model.notes.to_h do |note|
812
+ index = linked_positions[note.id]
813
+ [
814
+ note.id,
815
+ {
816
+ "docs_tree" => navigation.docs_tree,
817
+ "docs_home_url" => docs_home_url,
818
+ "related_articles" => related_article_cards(note, model, config),
819
+ "previous" => index && index.positive? ? docs_card(linked[index - 1], config) : nil,
820
+ "next" => index && index < linked.length - 1 ? docs_card(linked[index + 1], config) : nil
821
+ }
822
+ ]
823
+ end
824
+ attach_custom_tab_theme_data(theme_data, model, config)
825
+ system_theme_data = {
826
+ "docs_tree" => navigation.docs_tree,
827
+ "docs_home_url" => docs_home_url,
828
+ "previous" => nil,
829
+ "next" => nil
830
+ }
831
+ project(
832
+ model: model,
833
+ config: config,
834
+ landing_note: landing,
835
+ note_theme_data: theme_data,
836
+ theme_pages: [],
837
+ system_theme_data: system_theme_data,
838
+ taxonomy: taxonomy,
839
+ feed_notes: model.notes
840
+ )
841
+ end
842
+
843
+ private
844
+
845
+ def docs_card(note, config, linked: true)
846
+ {
847
+ "id" => note.id,
848
+ "title" => note.title,
849
+ "url" => linked && !note.nav_exclude ? config.url_builder.href(note.route) : nil,
850
+ "content_type" => note.content_type
851
+ }
852
+ end
853
+ end
854
+ end
855
+ end