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,1044 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "nokogiri"
5
+ require "psych"
6
+ require "set"
7
+ require "uri"
8
+
9
+ module JekyllObsidian
10
+ # Compiles every configured theme locale behind the existing VaultCompiler
11
+ # interface. The default tree remains authoritative; translated snapshots
12
+ # are overlays with the same logical paths.
13
+ class LocalizedCompiler
14
+ TRANSLATIONS_ROOT = "_translations"
15
+ LOCALE_MANIFEST = "_locale.yml"
16
+ TRANSLATABLE_PROPERTIES = %w[
17
+ title subtitle description tags author categories image cssclasses github_markdown related
18
+ ].freeze
19
+ STRUCTURAL_PROPERTIES = (FrontMatter::SUPPORTED - TRANSLATABLE_PROPERTIES - %w[publish tab]).freeze
20
+ URL_PROPERTIES = %w[
21
+ absolute_url canonical_url discussion_url docs_home_url
22
+ home_url href image markdown_url redirect_url route url
23
+ ].freeze
24
+ SOURCE_LINK_PROPERTIES = %w[edit history imported issue repository source].freeze
25
+ LOCALE_PATTERN = /\A[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*\z/
26
+ RESERVED_LOCALE_PREFIXES = %w[assets].freeze
27
+ MESSAGE_DEFAULTS = {
28
+ "language" => "Language",
29
+ "translation_missing" => "This page is not translated yet. The default-language version is shown.",
30
+ "skip_to_content" => "Skip to content",
31
+ "overview" => "Overview",
32
+ "documentation" => "Documentation",
33
+ "tags" => "Tags",
34
+ "all" => "All",
35
+ "filter_by_tag" => "Filter by tag",
36
+ "filter_by_topic" => "Filter by topic",
37
+ "chronology" => "Chronology",
38
+ "no_matching_entries" => "No entries match these filters.",
39
+ "contact" => "Contact",
40
+ "graph" => "Graph",
41
+ "feed" => "Feed",
42
+ "search" => "Search",
43
+ "scheme" => "Scheme",
44
+ "toggle_scheme" => "Toggle color scheme",
45
+ "use_light_scheme" => "Use light color scheme",
46
+ "use_dark_scheme" => "Use dark color scheme",
47
+ "light_scheme" => "Light",
48
+ "dark_scheme" => "Dark",
49
+ "page_context" => "Page context",
50
+ "mobile_actions" => "Mobile actions",
51
+ "browse" => "Browse",
52
+ "on_this_page" => "On this page",
53
+ "context" => "Context",
54
+ "previous" => "Previous",
55
+ "next" => "Next",
56
+ "manual" => "Manual",
57
+ "dispatch" => "Dispatch",
58
+ "note" => "Note",
59
+ "published" => "Published",
60
+ "posted_by" => "Posted by",
61
+ "on" => "on",
62
+ "updated" => "Updated",
63
+ "edit" => "Edit",
64
+ "history" => "History",
65
+ "view_source" => "View source",
66
+ "view_imported_markdown" => "View imported Markdown",
67
+ "open_project_on_github" => "Open {title} on GitHub",
68
+ "report_issue" => "Report issue",
69
+ "contribute" => "Contribute to this page",
70
+ "copy_page" => "Copy page",
71
+ "copy_page_description" => "Copy page as Markdown for LLMs",
72
+ "copy_code" => "Copy code",
73
+ "image" => "Image",
74
+ "expand_image" => "Expand image",
75
+ "close_image" => "Close image",
76
+ "copy_diagram_source" => "Copy diagram source",
77
+ "expand_diagram" => "Expand diagram",
78
+ "close_diagram" => "Close diagram",
79
+ "zoom_in" => "Zoom in",
80
+ "zoom_out" => "Zoom out",
81
+ "reset_zoom" => "Reset zoom",
82
+ "zoom_level" => "Zoom: {percent}%",
83
+ "copy_formula_source" => "Copy formula source",
84
+ "copy_code_failed" => "Copy failed",
85
+ "view_as_markdown" => "View as Markdown",
86
+ "view_as_markdown_description" => "View this page as plain text",
87
+ "more_page_actions" => "More page actions",
88
+ "copied" => "Copied",
89
+ "copy_failed" => "Copy failed. Open View as Markdown and copy the text.",
90
+ "opens_in_new_tab" => "opens in a new tab",
91
+ "documentation_sequence" => "Documentation sequence",
92
+ "search_title" => "Search this site",
93
+ "close_search" => "Close search",
94
+ "search_pages" => "Search pages",
95
+ "search_placeholder" => "Title, tag, or phrase",
96
+ "search_initial" => "The index loads when search opens.",
97
+ "search_loading" => "Loading notebook index…",
98
+ "search_prompt" => "Type a title, tag, or phrase.",
99
+ "search_unavailable" => "Search could not be loaded. Reload the page and try again.",
100
+ "search_no_results" => "No notes found for “{query}”.",
101
+ "search_result_one" => "{count} note found.",
102
+ "search_result_many" => "{count} notes found.",
103
+ "close_browse" => "Close browse menu",
104
+ "close_context" => "Close page context",
105
+ "outgoing_links" => "Outgoing links",
106
+ "direct_links" => "Direct links",
107
+ "backlinks" => "Backlinks",
108
+ "embedded_by" => "Embedded by",
109
+ "no_outgoing_links" => "No outgoing note links.",
110
+ "no_backlinks" => "No notes link here yet.",
111
+ "not_embedded" => "Not embedded elsewhere.",
112
+ "outline" => "On this page",
113
+ "no_headings" => "No headings on this page.",
114
+ "page_not_found" => "Page not found",
115
+ "page_not_found_description" => "The requested page is not in this published site.",
116
+ "return_home" => "Return home",
117
+ "primary_navigation" => "Primary navigation",
118
+ "home" => "Home",
119
+ "blog" => "Blog",
120
+ "docs" => "Docs",
121
+ "portfolio" => "Portfolio",
122
+ "projects" => "Projects",
123
+ "recent_posts" => "Recent posts",
124
+ "related_articles" => "Related articles",
125
+ "view_all" => "View all",
126
+ "more" => "More",
127
+ "interactive_graph" => "Interactive graph",
128
+ "open_full_graph" => "Open full graph",
129
+ "open_local_graph" => "Expand local graph",
130
+ "full_graph" => "Full graph",
131
+ "local_graph" => "Local graph",
132
+ "close_full_graph" => "Close full graph",
133
+ "close_local_graph" => "Close local graph",
134
+ "graph_loading" => "Loading graph…",
135
+ "graph_unavailable" => "The interactive graph could not be loaded. Use the linked notes below.",
136
+ "graph_too_large" => "This complete graph is too large to render interactively. Use local graphs or search instead.",
137
+ "graph_summary" => "{notes} notes and {relations} relations.",
138
+ "graph_node_label" => "{title}, {count} relations",
139
+ "graph_title" => "Note relation graph",
140
+ "graph_description" => "Linked notes are connected by solid lines; embedded notes use dashed lines.",
141
+ "diagram" => "Diagram",
142
+ "embed_cycle" => "Embed cycle",
143
+ "embedded_from" => "From {title}",
144
+ "missing_fragment" => "Missing fragment: {label}",
145
+ "download_pdf" => "Download PDF",
146
+ "download" => "Download",
147
+ "open_embedded_page" => "Open embedded page",
148
+ "view_post_on_x" => "View post on X",
149
+ "built_by" => "Built by",
150
+ "project_on_github" => "Jekyll Obsidian on GitHub",
151
+ "topics" => "Topics",
152
+ "notes" => "Notes",
153
+ "note_context" => "Note context",
154
+ "post_sequence" => "Post sequence",
155
+ "dated_posts" => "Dated posts",
156
+ "field_dispatches" => "Field dispatches",
157
+ "comments" => "Comments",
158
+ "comments_loading" => "Loading comments…",
159
+ "comments_development" => "Comments load only on the published site.",
160
+ "comments_setup_pending" => "Comments are not available yet. You can finish the GitHub Discussions setup later.",
161
+ "comments_unavailable" => "Comments could not be loaded. You can continue the conversation on GitHub.",
162
+ "comments_stored" => "Comments are stored in GitHub Discussions.",
163
+ "open_discussions" => "Open discussions on GitHub",
164
+ "open_comments_repository" => "Open the comments repository on GitHub"
165
+ }.freeze
166
+
167
+ def self.compile(request)
168
+ new(request).compile
169
+ end
170
+
171
+ def initialize(request)
172
+ @request = request
173
+ @config = request.config
174
+ @diagnostics = []
175
+ @entries = Array(request.snapshot.entries)
176
+ @entries_by_path = @entries.to_h { |entry| [entry.path.to_s, entry] }
177
+ @url_builder = UrlBuilder.new(origin: @config.url, baseurl: @config.baseurl)
178
+ @locales = []
179
+ @enabled = false
180
+ @locale_data = {}
181
+ @actual_by_locale = {}
182
+ @physical_sources = {}
183
+ @diagnostic_origins = {}
184
+ @default_locale = @config.lang.to_s
185
+ @validated_navigation_locales = Set.new
186
+ @shared_html_routes = Set.new
187
+ @html_publication = HtmlPublication.empty_resolution
188
+ @content_policy = ContentPolicy.resolve(nil).policy
189
+ rescue ArgumentError => exception
190
+ @diagnostics ||= []
191
+ error("invalid_url_config", exception.message)
192
+ end
193
+
194
+ def compile
195
+ validate_configuration
196
+ return failure if @diagnostics.any? { |item| item.severity == :error }
197
+ return compile_disabled unless @enabled
198
+
199
+ load_manifests if @diagnostics.none? { |item| item.severity == :error }
200
+ snapshots = build_locale_snapshots if @diagnostics.none? { |item| item.severity == :error }
201
+ return failure if @diagnostics.any? { |item| item.severity == :error }
202
+ resolve_html_publication(snapshots.fetch(@default_locale))
203
+ return failure if @diagnostics.any? { |item| item.severity == :error }
204
+
205
+ results = {}
206
+ tab_memberships = nil
207
+ ([@default_locale] + @locales.reject { |locale| locale == @default_locale }).each do |locale|
208
+ config = BuildConfig.new(**@config.to_h.merge(i18n: nil))
209
+ result = VaultCompiler.compile_single(
210
+ BuildRequest.new(
211
+ snapshot: snapshots.fetch(locale),
212
+ config: config,
213
+ tab_memberships: tab_memberships
214
+ ),
215
+ html_publication: @html_publication,
216
+ include_html_projection: false
217
+ )
218
+ @diagnostics.concat(localized_diagnostics(result.diagnostics, locale))
219
+ results[locale] = result
220
+ tab_memberships = tab_membership(result) if locale == @default_locale && result.success?
221
+ end
222
+ return failure if results.values.any? { |result| !result.success? }
223
+
224
+ combine(results)
225
+ rescue StandardError => exception
226
+ error("i18n_internal_error", exception.message)
227
+ failure
228
+ end
229
+
230
+ private
231
+
232
+ def compile_disabled
233
+ config = BuildConfig.new(**@config.to_h.merge(i18n: nil))
234
+ VaultCompiler.compile_single(BuildRequest.new(snapshot: @request.snapshot, config: config))
235
+ end
236
+
237
+ def validate_configuration
238
+ content_resolution = ContentPolicy.resolve(@config.content)
239
+ @diagnostics.concat(content_resolution.diagnostics)
240
+ @content_policy = content_resolution.policy
241
+
242
+ raw = @config.i18n
243
+ unless raw.is_a?(Hash)
244
+ error("invalid_i18n_config", "website.i18n must be a mapping")
245
+ return
246
+ end
247
+ unknown = raw.keys.map(&:to_s) - %w[enabled locales]
248
+ unknown.each { |key| error("invalid_i18n_config", "unknown i18n setting #{key.inspect}") }
249
+ enabled = fetch(raw, "enabled") if raw.key?("enabled") || raw.key?(:enabled)
250
+ unless enabled.nil? || enabled == true || enabled == false
251
+ error("invalid_i18n_config", "website.i18n.enabled must be a YAML boolean")
252
+ return
253
+ end
254
+ configured_theme = @config.theme.to_s
255
+ configured_theme = "minimal" if configured_theme.empty?
256
+ @enabled = enabled.nil? ? configured_theme == "docs" : enabled
257
+ locales = fetch(raw, "locales")
258
+ return unless @enabled || !locales.nil?
259
+
260
+ unless locales.is_a?(Array) && !locales.empty? && locales.all? { |locale| locale.is_a?(String) }
261
+ error("invalid_i18n_locales", "website.i18n.locales must be a non-empty array of locale strings")
262
+ return
263
+ end
264
+ folded = Set.new
265
+ locales.each do |locale|
266
+ unless locale.match?(LOCALE_PATTERN) && locale == locale.unicode_normalize(:nfc)
267
+ error("invalid_locale", "locale must be a normalized BCP 47-style tag", locale)
268
+ next
269
+ end
270
+ if RESERVED_LOCALE_PREFIXES.include?(locale.downcase(:fold))
271
+ error("reserved_locale", "locale conflicts with a reserved public route prefix", locale)
272
+ next
273
+ end
274
+ key = locale.downcase(:fold)
275
+ if folded.include?(key)
276
+ error("duplicate_locale", "locales must be unique under case-folding", locale)
277
+ else
278
+ folded << key
279
+ @locales << locale
280
+ end
281
+ rescue EncodingError
282
+ error("invalid_locale", "locale must contain valid Unicode", locale)
283
+ end
284
+ if @enabled && !@locales.include?(@default_locale)
285
+ error("missing_default_locale", "lang must name one of website.i18n.locales")
286
+ end
287
+ end
288
+
289
+ def resolve_html_publication(snapshot)
290
+ @html_publication = HtmlPublication.resolve(
291
+ snapshot:,
292
+ mappings: @config.html,
293
+ url_builder: @url_builder
294
+ )
295
+ @diagnostics.concat(@html_publication.diagnostics)
296
+ end
297
+
298
+ def load_manifests
299
+ @locales.each do |locale|
300
+ path = locale == @default_locale ? LOCALE_MANIFEST : "#{TRANSLATIONS_ROOT}/#{locale}/#{LOCALE_MANIFEST}"
301
+ entry = @entries_by_path[path]
302
+ unless entry
303
+ error("missing_locale_manifest", "configured locale requires #{path}", path)
304
+ next
305
+ end
306
+ begin
307
+ raw = Psych.safe_load(entry.bytes.to_s, permitted_classes: [], aliases: false)
308
+ rescue Psych::Exception => exception
309
+ error("invalid_locale_manifest", exception.message, path)
310
+ next
311
+ end
312
+ unless raw.is_a?(Hash) && raw.keys.all? { |key| key.is_a?(String) }
313
+ error("invalid_locale_manifest", "locale manifest must be a YAML mapping with string keys", path)
314
+ next
315
+ end
316
+ unknown = raw.keys - %w[name hreflang dir messages]
317
+ unknown.each { |key| error("invalid_locale_manifest", "unknown locale setting #{key.inspect}", path) }
318
+ name = raw["name"]
319
+ unless valid_text?(name) && !name.empty?
320
+ error("invalid_locale_manifest", "name must be a non-empty string", path)
321
+ name = locale
322
+ end
323
+ hreflang = raw.fetch("hreflang", locale)
324
+ error("invalid_locale_manifest", "hreflang must be a non-empty string", path) unless valid_text?(hreflang) && !hreflang.empty?
325
+ direction = raw.fetch("dir", "ltr")
326
+ error("invalid_locale_manifest", "dir must be ltr or rtl", path) unless %w[ltr rtl].include?(direction)
327
+ messages = raw.fetch("messages", {})
328
+ unless messages.is_a?(Hash) && messages.keys.all? { |key| key.is_a?(String) }
329
+ error("invalid_locale_manifest", "messages must be a mapping with string keys", path)
330
+ messages = {}
331
+ end
332
+ messages.each do |key, value|
333
+ error("invalid_locale_message", "unknown message key #{key.inspect}", path) unless MESSAGE_DEFAULTS.key?(key)
334
+ error("invalid_locale_message", "message #{key.inspect} must be a string", path) unless valid_text?(value)
335
+ end
336
+ @locale_data[locale] = {
337
+ "code" => locale,
338
+ "name" => name,
339
+ "hreflang" => hreflang,
340
+ "dir" => direction,
341
+ "messages" => MESSAGE_DEFAULTS.merge(messages.select { |key, value| MESSAGE_DEFAULTS.key?(key) && valid_text?(value) })
342
+ }
343
+ end
344
+ end
345
+
346
+ def build_locale_snapshots
347
+ default_entries = @entries.reject do |entry|
348
+ entry.path == LOCALE_MANIFEST || entry.path.to_s.start_with?("#{TRANSLATIONS_ROOT}/")
349
+ end
350
+ default_public = default_entries.filter_map do |entry|
351
+ next unless entry.kind.to_sym == :note
352
+ parsed = FrontMatter.parse(entry.path, entry.bytes.to_s)
353
+ @diagnostics.concat(parsed.diagnostics)
354
+ [entry.path, [entry, parsed]] if @content_policy.publish?(entry.path, parsed.properties)
355
+ end.to_h
356
+
357
+ configured_roots = @locales.reject { |locale| locale == @default_locale }.map { |locale| "#{TRANSLATIONS_ROOT}/#{locale}/" }
358
+ @entries.each do |entry|
359
+ next unless entry.path.to_s.start_with?("#{TRANSLATIONS_ROOT}/")
360
+ next if configured_roots.any? { |root| entry.path.to_s.start_with?(root) }
361
+
362
+ error("unknown_translation_locale", "translation content belongs to an unconfigured locale", entry.path)
363
+ end
364
+
365
+ snapshots = { @default_locale => Snapshot.new(entries: default_entries) }
366
+ @actual_by_locale[@default_locale] = Set.new(default_public.keys)
367
+ @physical_sources[@default_locale] = default_public.keys.to_h { |path| [path, path] }
368
+
369
+ @locales.each do |locale|
370
+ next if locale == @default_locale
371
+
372
+ prefix = "#{TRANSLATIONS_ROOT}/#{locale}/"
373
+ translated = {}
374
+ @entries.select { |entry| entry.path.to_s.start_with?(prefix) }.each do |entry|
375
+ logical_path = entry.path.to_s.delete_prefix(prefix)
376
+ next if logical_path == LOCALE_MANIFEST
377
+ unless valid_logical_path?(logical_path)
378
+ error("invalid_translation_path", "translation paths must be normalized vault-relative POSIX paths", entry.path)
379
+ next
380
+ end
381
+ unless entry.kind.to_sym == :note
382
+ error("localized_asset_unsupported", "localized binary assets are not supported; reference a shared default asset", entry.path)
383
+ next
384
+ end
385
+ default_pair = default_public[logical_path]
386
+ unless default_pair
387
+ error("orphan_translation", "translation has no published default-language counterpart", entry.path)
388
+ next
389
+ end
390
+ translated_parse = FrontMatter.parse(entry.path, entry.bytes.to_s)
391
+ @diagnostics.concat(translated_parse.diagnostics)
392
+ next if translated_parse.properties["publish"] == false
393
+ default_entry, default_parse = default_pair
394
+ STRUCTURAL_PROPERTIES.each do |property|
395
+ next unless translated_parse.properties.key?(property)
396
+ next if translated_parse.properties[property] == default_parse.properties[property]
397
+
398
+ error("localized_structure_override", "translation must not change #{property}", entry.path)
399
+ end
400
+ merged = merge_translation_properties(
401
+ default_parse.properties,
402
+ translated_parse.properties,
403
+ entry.path
404
+ )
405
+ bytes = "#{Psych.dump(merged)}---\n#{translated_parse.body}"
406
+ register_property_link_origins(
407
+ locale,
408
+ logical_path,
409
+ bytes,
410
+ default_entry.path,
411
+ default_parse,
412
+ entry.path,
413
+ translated_parse
414
+ )
415
+ translated[logical_path] = SnapshotEntry.new(**entry.to_h.merge(
416
+ path: logical_path,
417
+ bytes: bytes,
418
+ size: bytes.bytesize,
419
+ first_committed_at: default_entry.first_committed_at
420
+ ))
421
+ (@physical_sources[locale] ||= {})[logical_path] = entry.path
422
+ end
423
+ @actual_by_locale[locale] = Set.new(translated.keys)
424
+ @physical_sources[locale] ||= {}
425
+ overlaid = default_entries.map do |entry|
426
+ entry.kind.to_sym == :note && translated.key?(entry.path) ? translated.fetch(entry.path) : entry
427
+ end
428
+ snapshots[locale] = Snapshot.new(entries: overlaid)
429
+ end
430
+ snapshots
431
+ end
432
+
433
+ def register_property_link_origins(
434
+ locale,
435
+ logical_path,
436
+ synthetic_bytes,
437
+ default_path,
438
+ default_parse,
439
+ translated_path,
440
+ translated_parse
441
+ )
442
+ synthetic_parse = FrontMatter.parse(logical_path, synthetic_bytes)
443
+ origins = (@diagnostic_origins[locale] ||= {})
444
+ Array(synthetic_parse.property_links).each do |link|
445
+ translated = translated_parse.properties.key?(link.property)
446
+ source_parse = translated ? translated_parse : default_parse
447
+ source_path = translated ? translated_path : default_path
448
+ source_link = Array(source_parse.property_links).find do |candidate|
449
+ candidate.property == link.property && candidate.index == link.index
450
+ end
451
+ next unless source_link
452
+
453
+ origins[diagnostic_origin_key(logical_path, link.source_span, link.property)] = [source_path, source_link.source_span]
454
+ end
455
+ end
456
+
457
+ def localized_diagnostics(diagnostics, locale)
458
+ return diagnostics if locale == @default_locale
459
+
460
+ diagnostics.map do |diagnostic|
461
+ origin = @diagnostic_origins.fetch(locale, {})[
462
+ diagnostic_origin_key(diagnostic.path, diagnostic.span, diagnostic.property)
463
+ ]
464
+ path, span = if origin
465
+ origin
466
+ else
467
+ [@physical_sources.fetch(locale, {}).fetch(diagnostic.path, diagnostic.path), diagnostic.span]
468
+ end
469
+ Diagnostic.new(**diagnostic.to_h.merge(path: path, span: span))
470
+ end
471
+ end
472
+
473
+ def diagnostic_origin_key(path, span, property)
474
+ [
475
+ path.to_s,
476
+ span&.start_line,
477
+ span&.start_column,
478
+ span&.end_line,
479
+ span&.end_column,
480
+ property
481
+ ]
482
+ end
483
+
484
+ def merge_translation_properties(default_properties, translated_properties, physical_path)
485
+ merged = default_properties.merge(translated_properties)
486
+ merged = merged.reject { |key, _| key == "github_markdown" } unless translated_properties.key?("github_markdown")
487
+ return merged unless translated_properties.key?("tab")
488
+
489
+ default_tab = default_properties["tab"]
490
+ translated_tab = translated_properties.fetch("tab")
491
+ unless default_tab
492
+ error(
493
+ "localized_structure_override",
494
+ "translation may only override tab.label when the default-language note declares tab",
495
+ physical_path
496
+ )
497
+ return merged.reject { |key, _| key == "tab" }
498
+ end
499
+
500
+ %w[id order topics].each do |property|
501
+ next unless translated_tab.key?(property)
502
+
503
+ error(
504
+ "localized_structure_override",
505
+ "translation must inherit tab.#{property} from the default language",
506
+ physical_path
507
+ )
508
+ end
509
+ label_override = translated_tab.key?("label") ? { "label" => translated_tab.fetch("label") } : {}
510
+ merged.merge("tab" => default_tab.merge(label_override))
511
+ end
512
+
513
+ def combine(results)
514
+ pages = []
515
+ generated_files = []
516
+ projected_files = {}
517
+ notes = []
518
+ relations = []
519
+ default_result = results.fetch(@default_locale)
520
+ @shared_html_routes = raw_html_routes
521
+ default_routes = note_routes(default_result)
522
+ default_navigation_order = navigation_order(default_result)
523
+
524
+ @locales.each do |locale|
525
+ result = results.fetch(locale)
526
+ result.pages.each { |page| pages << localize_page(page, locale, default_routes, default_navigation_order) }
527
+ result.generated_files.each do |file|
528
+ localized = localize_generated_file(file, locale)
529
+ generated_files << localized if localized
530
+ end
531
+ result.projected_files.each { |file| projected_files[file.route] ||= file }
532
+ result.notes.each do |note|
533
+ notes << NoteOutput.new(
534
+ id: locale == @default_locale ? note.id : "#{locale}:#{note.id}",
535
+ title: note.title,
536
+ route: prefixed_route(note.route, locale),
537
+ properties: note.properties
538
+ )
539
+ end
540
+ result.relations.each do |relation|
541
+ prefix = locale == @default_locale ? "" : "#{locale}:"
542
+ relations << Relation.new(**relation.to_h.merge(
543
+ source_id: "#{prefix}#{relation.source_id}",
544
+ target_id: "#{prefix}#{relation.target_id}"
545
+ ))
546
+ end
547
+ end
548
+
549
+ generated_files.reject! { |file| file.route == "/sitemap.xml" }
550
+ generated_files << GeneratedFile.new(route: "/sitemap.xml", content: sitemap_xml(pages), media_type: "application/xml")
551
+ # Raw HTML is constructed only after locale outputs are localized, so its
552
+ # shared manifest can never enter localize_generated_file.
553
+ raw_html_manifest = HtmlPublication.manifest(@html_publication)
554
+ generated_files << raw_html_manifest if raw_html_manifest
555
+ @html_publication.projected_files.each { |file| projected_files[file.route] ||= file }
556
+ preflight_routes(pages, generated_files, projected_files.values)
557
+ return failure if @diagnostics.any? { |item| item.severity == :error }
558
+
559
+ BuildSuccess.new(
560
+ pages: pages.sort_by(&:route),
561
+ generated_files: generated_files.sort_by(&:route),
562
+ projected_files: projected_files.values.sort_by(&:route),
563
+ diagnostics: sorted_diagnostics,
564
+ relations: relations.freeze,
565
+ notes: notes.freeze,
566
+ theme: default_result.theme,
567
+ features: default_result.features,
568
+ site_data: default_result.site_data.merge(
569
+ "website_i18n" => {
570
+ "default_locale" => @default_locale,
571
+ "locales" => @locales.map { |locale| @locale_data.fetch(locale).reject { |key, _| key == "messages" } }
572
+ }
573
+ )
574
+ )
575
+ end
576
+
577
+ def localize_page(page, locale, default_routes, default_navigation_order)
578
+ route = prefixed_route(page.route, locale)
579
+ data = localize_data(page.data, locale)
580
+ website = data.fetch("website")
581
+ kind = website.fetch("kind")
582
+ note_id = website["id"]
583
+ authored_page = kind == "note" || (kind == "home" && note_id)
584
+ actual = !authored_page || locale == @default_locale || @actual_by_locale.fetch(locale).include?(note_id)
585
+ default_route = kind == "note" ? default_routes.fetch(note_id) : page.route
586
+ localizations = @locales.map do |candidate|
587
+ candidate_actual = !authored_page || candidate == @default_locale || @actual_by_locale.fetch(candidate).include?(note_id)
588
+ locale_data = @locale_data.fetch(candidate)
589
+ {
590
+ "locale" => candidate,
591
+ "name" => locale_data.fetch("name"),
592
+ "hreflang" => locale_data.fetch("hreflang"),
593
+ "dir" => locale_data.fetch("dir"),
594
+ "href" => @url_builder.href(prefixed_route(default_route, candidate)),
595
+ "fallback" => !candidate_actual
596
+ }
597
+ end
598
+ alternates = if actual
599
+ localizations.reject { |item| item.fetch("fallback") }.map do |item|
600
+ item.merge("href" => absolute_for(item.fetch("href")))
601
+ end.then do |items|
602
+ default = items.find { |item| item.fetch("locale") == @default_locale }
603
+ default ? items + [{ "hreflang" => "x-default", "href" => default.fetch("href") }] : items
604
+ end
605
+ else
606
+ []
607
+ end
608
+ locale_data = @locale_data.fetch(locale)
609
+ content_locale_data = actual ? locale_data : @locale_data.fetch(@default_locale)
610
+ website["route"] = route
611
+ website["href"] = @url_builder.href(route)
612
+ website["alternates"] = alternates
613
+ website["robots"] = "noindex" unless actual
614
+ website["resources"] = resource_urls(locale)
615
+ website["home_route"] = prefixed_route(website.fetch("home_route", "/"), locale)
616
+ localize_navigation_labels!(website, locale_data.fetch("messages"))
617
+ preserve_default_navigation_order!(website, default_navigation_order)
618
+ routes = website.fetch("routes", {}).to_h.transform_values do |url|
619
+ localize_url(url, locale)
620
+ end
621
+ Array(website["navigation"]).each { |item| routes[item.fetch("id")] = item.fetch("url") }
622
+ routes["home"] = website.fetch("home_url")
623
+ if website.fetch("theme") == "docs" && website.fetch("features").fetch("tags")
624
+ routes["tags"] = @url_builder.href(prefixed_route("/tags/", locale))
625
+ end
626
+ if website.fetch("features").fetch("feed")
627
+ routes["feed"] = @url_builder.href(prefixed_route("/feed.xml", locale))
628
+ end
629
+ website["routes"] = routes
630
+ if kind == "redirect"
631
+ destination = Array(website["navigation"]).first
632
+ if destination
633
+ website["redirect_url"] = destination.fetch("url")
634
+ data["title"] = destination.fetch("label")
635
+ elsif website["redirect_navigation_id"] == "portfolio"
636
+ data["title"] = localized_builtin_label(website, "portfolio", locale_data.fetch("messages"))
637
+ end
638
+ end
639
+ validate_localized_navigation!(website, locale)
640
+ website["canonical_url"] = if kind == "redirect"
641
+ absolute_for(website.fetch("redirect_url"))
642
+ elsif actual
643
+ @url_builder.absolute_url(route)
644
+ else
645
+ @url_builder.absolute_url(default_route)
646
+ end
647
+ website["i18n"] = {
648
+ "locale" => locale,
649
+ "name" => locale_data.fetch("name"),
650
+ "hreflang" => locale_data.fetch("hreflang"),
651
+ "dir" => locale_data.fetch("dir"),
652
+ "messages" => locale_data.fetch("messages"),
653
+ "content_lang" => actual ? locale : @default_locale,
654
+ "content_dir" => content_locale_data.fetch("dir"),
655
+ "fallback" => !actual,
656
+ "localizations" => localizations
657
+ }
658
+ if website["comments"]
659
+ website["comments"]["language"] = VaultCompiler.giscus_language(locale)
660
+ end
661
+ localize_page_copy!(data, kind, locale_data.fetch("messages"))
662
+ if %w[note home].include?(kind) && note_id && actual && locale != @default_locale && @actual_by_locale.fetch(locale).include?(note_id)
663
+ imported = website.dig("source_links", "imported")
664
+ repository = website.dig("source_links", "repository")
665
+ website["source_links"] = repository_links(@physical_sources.fetch(locale).fetch(note_id), note_id)
666
+ website["source_links"]["imported"] = imported if imported
667
+ website["source_links"]["repository"] = repository if repository
668
+ end
669
+ content = localize_html(page.content, locale)
670
+ PageOutput.new(route: route, content: content, data: data)
671
+ end
672
+
673
+ def localize_generated_file(file, locale)
674
+ return nil if locale != @default_locale && file.route == "/sitemap.xml"
675
+
676
+ route = generated_route(file.route, locale)
677
+ content = case file.media_type
678
+ when "application/json"
679
+ "#{JSON.generate(localize_data(JSON.parse(file.content), locale))}\n"
680
+ when "text/html"
681
+ localize_html(file.content, locale)
682
+ when "application/atom+xml", "application/xml"
683
+ localize_xml(file.content, locale)
684
+ else
685
+ file.content
686
+ end
687
+ GeneratedFile.new(route: route, content: content, media_type: file.media_type)
688
+ end
689
+
690
+ def generated_route(route, locale)
691
+ return route if locale == @default_locale
692
+ if route.start_with?("/assets/website/")
693
+ basename = route.delete_prefix("/assets/website/")
694
+ return "/assets/website/i18n/#{locale}/#{basename}"
695
+ end
696
+ prefixed_route(route, locale)
697
+ end
698
+
699
+ def resource_urls(locale)
700
+ prefix = locale == @default_locale ? "/assets/website" : "/assets/website/i18n/#{locale}"
701
+ {
702
+ "search" => @url_builder.href("#{prefix}/search.v1.json"),
703
+ "catalog" => @url_builder.href("#{prefix}/catalog.v1.json"),
704
+ "preview" => @url_builder.href("#{prefix}/catalog.v1.json"),
705
+ "graph" => @url_builder.href("#{prefix}/graph.v1.json"),
706
+ "feed" => @url_builder.href(prefixed_route("/feed.xml", locale))
707
+ }
708
+ end
709
+
710
+ def localize_data(value, locale, property = nil, parent_property = nil)
711
+ case value
712
+ when Hash
713
+ value.to_h do |key, item|
714
+ [key, localize_data(item, locale, key.to_s, property)]
715
+ end
716
+ when Array
717
+ value.map { |item| localize_data(item, locale, property, parent_property) }
718
+ when String
719
+ localizable_url_property?(property, parent_property) ? localize_url(value, locale) : value
720
+ else
721
+ value
722
+ end
723
+ end
724
+
725
+ def localizable_url_property?(property, parent_property)
726
+ URL_PROPERTIES.include?(property) ||
727
+ (parent_property == "source_links" && SOURCE_LINK_PROPERTIES.include?(property))
728
+ end
729
+
730
+ def localize_html(html, locale)
731
+ fragment = Nokogiri::HTML5.fragment(html)
732
+ fragment.css("[href], [src], [poster]").each do |node|
733
+ %w[href src poster].each { |attribute| node[attribute] = localize_url(node[attribute], locale) if node[attribute] }
734
+ end
735
+ localize_generated_copy(fragment, locale)
736
+ fragment.to_html
737
+ end
738
+
739
+ def localize_generated_copy(fragment, locale)
740
+ messages = @locale_data.fetch(locale).fetch("messages")
741
+ fragment.css("[data-website-mermaid]").each { |node| node["data-diagram-label"] = messages.fetch("diagram") }
742
+ fragment.css(".website-embed--cycle").each do |node|
743
+ replace_generated_text(node, messages.fetch("embed_cycle"), locale) if node.text == MESSAGE_DEFAULTS.fetch("embed_cycle")
744
+ end
745
+ fragment.css(".website-transclusion__source").each do |node|
746
+ next unless node.text.start_with?("From ")
747
+
748
+ title = node.text.delete_prefix("From ")
749
+ replace_generated_text(node, messages.fetch("embedded_from").sub("{title}", title), locale)
750
+ end
751
+ fragment.css(".website-embed--unresolved").each do |node|
752
+ match = node.text.match(/\AMissing fragment: (?<label>.*)\z/m)
753
+ replace_generated_text(node, messages.fetch("missing_fragment").sub("{label}", match[:label]), locale) if match
754
+ end
755
+ fragment.css("object[type='application/pdf'] > a").each do |node|
756
+ replace_generated_text(node, messages.fetch("download_pdf"), locale) if node.text == MESSAGE_DEFAULTS.fetch("download_pdf")
757
+ end
758
+ fragment.css(".website-download-card__meta").each do |node|
759
+ replace_generated_text(node, messages.fetch("download"), locale) if node.text == MESSAGE_DEFAULTS.fetch("download")
760
+ end
761
+ fragment.css(".website-external-frame__fallback").each do |node|
762
+ replace_generated_text(node, messages.fetch("open_embedded_page"), locale) if node.text == MESSAGE_DEFAULTS.fetch("open_embedded_page")
763
+ end
764
+ fragment.css(".website-tweet__fallback").each do |node|
765
+ replace_generated_text(node, messages.fetch("view_post_on_x"), locale) if node.text == MESSAGE_DEFAULTS.fetch("view_post_on_x")
766
+ end
767
+ end
768
+
769
+ def replace_generated_text(node, text, locale)
770
+ node.content = text
771
+ node["lang"] = locale if locale != @default_locale
772
+ end
773
+
774
+ def localize_xml(xml, locale)
775
+ document = Nokogiri::XML(xml) { |config| config.strict.nonet }
776
+ document.xpath("//*[@href]").each { |node| node["href"] = localize_url(node["href"], locale) }
777
+ document.xpath("//*[local-name()='id']").each { |node| node.content = localize_url(node.text, locale) }
778
+ document.to_xml
779
+ rescue Nokogiri::XML::SyntaxError
780
+ xml
781
+ end
782
+
783
+ def localize_url(value, locale)
784
+ return value if locale == @default_locale || value.empty? || value.start_with?("#")
785
+
786
+ absolute = !@url_builder.origin.empty? && value.start_with?(@url_builder.origin)
787
+ raw = absolute ? value.delete_prefix(@url_builder.origin) : value
788
+ return value unless raw.start_with?("/")
789
+
790
+ path, suffix = raw.split(/(?=[?#])/, 2)
791
+ relative = if @url_builder.baseurl.empty?
792
+ path
793
+ elsif path == @url_builder.baseurl || path.start_with?("#{@url_builder.baseurl}/")
794
+ path.delete_prefix(@url_builder.baseurl)
795
+ else
796
+ return value
797
+ end
798
+ relative = "/" if relative.empty?
799
+ return value if @shared_html_routes.include?(relative)
800
+
801
+ localized = if relative.start_with?("/assets/vault/", "/assets/website/")
802
+ relative
803
+ else
804
+ prefixed_route(relative, locale)
805
+ end
806
+ result = @url_builder.href(localized)
807
+ result = "#{@url_builder.origin}#{result}" if absolute
808
+ "#{result}#{suffix}"
809
+ end
810
+
811
+ def raw_html_routes
812
+ document_routes = @html_publication.documents.map { |document| document.fetch("route") }
813
+ Set.new(document_routes + @html_publication.files)
814
+ end
815
+
816
+ def prefixed_route(route, locale)
817
+ return route if locale == @default_locale
818
+ return "/#{locale}/" if route == "/"
819
+
820
+ "/#{locale}#{route}"
821
+ end
822
+
823
+ def note_routes(result)
824
+ result.pages.filter_map do |page|
825
+ website = page.data["website"]
826
+ [website["id"], page.route] if website&.fetch("kind", nil) == "note"
827
+ end.to_h
828
+ end
829
+
830
+ def navigation_order(result)
831
+ page = result.pages.find { |candidate| candidate.data.dig("website", "navigation") }
832
+ Array(page&.data&.dig("website", "navigation")).map { |item| item.fetch("id") }
833
+ end
834
+
835
+ def tab_membership(result)
836
+ result.pages.filter_map do |page|
837
+ website = page.data["website"]
838
+ ids = website&.dig("theme_data", "tab_member_ids")
839
+ [website.dig("theme_data", "tab_id"), ids] if website && ids
840
+ end.to_h
841
+ end
842
+
843
+ def preserve_default_navigation_order!(website, default_order)
844
+ positions = default_order.each_with_index.to_h
845
+ navigation = Array(website["navigation"])
846
+ website["navigation"] = navigation.each_with_index.sort_by do |item, index|
847
+ [positions.fetch(item.fetch("id"), positions.length), index]
848
+ end.map(&:first)
849
+ end
850
+
851
+ def repository_links(path, logical_path)
852
+ repository = @config.repository.to_s
853
+ return {} unless repository.match?(/\A[\w.-]+\/[\w.-]+\z/)
854
+
855
+ branch = URI.encode_uri_component(@config.edit_branch.to_s.empty? ? "main" : @config.edit_branch.to_s)
856
+ source = [@config.source.to_s, path].reject(&:empty?).map do |part|
857
+ part.split("/").map { |segment| URI.encode_uri_component(segment) }.join("/")
858
+ end.join("/")
859
+ base = "https://github.com/#{repository}"
860
+ {
861
+ "edit" => "#{base}/edit/#{branch}/#{source}",
862
+ "history" => "#{base}/commits/#{branch}/#{source}",
863
+ "source" => "#{base}/blob/#{branch}/#{source}",
864
+ "issue" => "#{base}/issues/new?title=#{URI.encode_uri_component("Issue with #{logical_path}")}"
865
+ }
866
+ end
867
+
868
+ def absolute_for(href)
869
+ return href if @url_builder.origin.empty?
870
+
871
+ "#{@url_builder.origin}#{href}"
872
+ end
873
+
874
+ def sitemap_xml(pages)
875
+ body = pages.reject do |page|
876
+ page.data.dig("website", "kind") == "redirect" ||
877
+ page.route.end_with?("/404.html") ||
878
+ page.data.dig("website", "i18n", "fallback")
879
+ end.sort_by(&:route).map do |page|
880
+ website = page.data.fetch("website")
881
+ alternates = Array(website["alternates"]).map do |alternate|
882
+ %( <xhtml:link rel="alternate" hreflang="#{escape(alternate.fetch('hreflang'))}" href="#{escape(alternate.fetch('href'))}" />)
883
+ end
884
+ lines = [" <url>", " <loc>#{escape(@url_builder.absolute_url(page.route))}</loc>", *alternates, " </url>"]
885
+ lines.join("\n")
886
+ end.join("\n")
887
+ %(<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">\n#{body}\n</urlset>\n)
888
+ end
889
+
890
+ def preflight_routes(pages, files, assets)
891
+ registry = DestinationRegistry.new
892
+ (pages + files + assets).each do |output|
893
+ destination = destination_key(output)
894
+ conflict = registry.add(destination, output)
895
+ if conflict
896
+ error(
897
+ "route_collision",
898
+ "#{output_owner(output)} at #{output.route} collides with #{output_owner(conflict)} at #{conflict.route}",
899
+ output.is_a?(ProjectedFile) ? output.source_path : output.route
900
+ )
901
+ end
902
+ end
903
+ end
904
+
905
+ def output_owner(output)
906
+ if output.is_a?(ProjectedFile)
907
+ "projected source #{output.source_path}"
908
+ elsif output.is_a?(PageOutput)
909
+ note_id = output.data.dig("website", "id")
910
+ note_id ? "note #{note_id}" : "theme page"
911
+ else
912
+ "generated artifact"
913
+ end
914
+ end
915
+
916
+ def destination_key(output)
917
+ key = @url_builder.collision_key(output.route)
918
+ if output.is_a?(PageOutput)
919
+ key.end_with?("/") ? "#{key}index.html" : key
920
+ else
921
+ output.route.end_with?("/") ? key : key.delete_suffix("/")
922
+ end
923
+ end
924
+
925
+ def fetch(hash, key)
926
+ hash.key?(key) ? hash[key] : hash[key.to_sym]
927
+ end
928
+
929
+ def valid_text?(value)
930
+ FrontMatter.valid_output_text?(value)
931
+ end
932
+
933
+ def valid_logical_path?(path)
934
+ return false unless valid_text?(path) && path == path.unicode_normalize(:nfc)
935
+ return false if path.empty? || path.start_with?("/", "\\") || path.include?("\\")
936
+
937
+ path.split("/", -1).none? { |segment| segment.empty? || segment == "." || segment == ".." }
938
+ rescue EncodingError
939
+ false
940
+ end
941
+
942
+ def localize_page_copy!(data, kind, messages)
943
+ if kind == "blog-index"
944
+ data["title"] = localized_builtin_label(data.fetch("website"), "blog", messages)
945
+ return
946
+ end
947
+ if kind == "portfolio-index"
948
+ data["title"] = localized_builtin_label(data.fetch("website"), "portfolio", messages)
949
+ return
950
+ end
951
+ if kind == "home" && !data.dig("website", "id")
952
+ data["title"] = localized_builtin_label(data.fetch("website"), "home", messages)
953
+ return
954
+ end
955
+
956
+ title_key = {
957
+ "tags" => "tags",
958
+ "graph" => "graph",
959
+ "404" => "page_not_found"
960
+ }[kind]
961
+ data["title"] = messages.fetch(title_key) if title_key
962
+ data["description"] = messages.fetch("page_not_found_description") if kind == "404"
963
+ end
964
+
965
+ def localize_navigation_labels!(website, messages)
966
+ labels = if website.fetch("theme") == "minimal"
967
+ { "home" => "home", "blog" => "blog", "docs" => "docs", "portfolio" => "portfolio" }
968
+ else
969
+ { "home" => "overview", "docs" => "documentation" }
970
+ end
971
+ Array(website["navigation"]).each do |item|
972
+ message_key = labels[item["id"]]
973
+ next unless message_key
974
+
975
+ item["label"] = messages.fetch(message_key) unless configured_navigation_label(item["id"])
976
+ end
977
+ end
978
+
979
+ def validate_localized_navigation!(website, locale)
980
+ return unless @validated_navigation_locales.add?(locale)
981
+
982
+ labels = {}
983
+ Array(website["navigation"]).each do |item|
984
+ label = item["label"]
985
+ unless valid_text?(label) && !label.strip.empty?
986
+ error(
987
+ "invalid_navigation_label",
988
+ "localized navigation label must be a non-empty string containing only output-safe Unicode characters",
989
+ locale_manifest_path(locale)
990
+ )
991
+ next
992
+ end
993
+
994
+ key = label.strip.unicode_normalize(:nfc).downcase(:fold)
995
+ if labels.key?(key)
996
+ error(
997
+ "duplicate_navigation_label",
998
+ "localized navigation labels must be unique; #{item.fetch("id").inspect} conflicts with #{labels.fetch(key).inspect}",
999
+ locale_manifest_path(locale)
1000
+ )
1001
+ else
1002
+ labels[key] = item.fetch("id")
1003
+ end
1004
+ rescue EncodingError
1005
+ error("invalid_navigation_label", "localized navigation label must contain valid Unicode", locale_manifest_path(locale))
1006
+ end
1007
+ end
1008
+
1009
+ def locale_manifest_path(locale)
1010
+ locale == @default_locale ? LOCALE_MANIFEST : "#{TRANSLATIONS_ROOT}/#{locale}/#{LOCALE_MANIFEST}"
1011
+ end
1012
+
1013
+ def localized_builtin_label(website, id, messages)
1014
+ item = Array(website["navigation"]).find { |candidate| candidate["id"] == id }
1015
+ item&.fetch("label") || configured_navigation_label(id) || messages.fetch(id)
1016
+ end
1017
+
1018
+ def configured_navigation_label(id)
1019
+ configured = @config.navigation.is_a?(Hash) ? @config.navigation : {}
1020
+ override = configured[id] || configured[id.to_sym]
1021
+ return unless override.is_a?(Hash)
1022
+
1023
+ override["label"] || override[:label]
1024
+ end
1025
+
1026
+ def escape(value)
1027
+ CGI.escapeHTML(value.to_s)
1028
+ end
1029
+
1030
+ def error(code, message, path = nil)
1031
+ @diagnostics << Diagnostic.new(severity: :error, code: code, message: message, path: path, span: nil)
1032
+ end
1033
+
1034
+ def sorted_diagnostics
1035
+ @diagnostics.sort_by do |item|
1036
+ [item.path.to_s.b, item.span&.start_line.to_i, item.property.to_s, item.code.to_s, item.message.to_s]
1037
+ end.freeze
1038
+ end
1039
+
1040
+ def failure
1041
+ BuildFailure.new(diagnostics: sorted_diagnostics)
1042
+ end
1043
+ end
1044
+ end