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