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,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "runtime"
5
+ require_relative "initializer"
6
+
7
+ module JekyllObsidian
8
+ class CLI
9
+ def self.run(arguments = ARGV)
10
+ new.run(arguments.dup)
11
+ end
12
+
13
+ def run(arguments)
14
+ command = arguments.shift || "--help"
15
+ if %w[--version -v].include?(command)
16
+ puts "jekyll-obsidian #{VERSION} (#{GEM_NAME})"
17
+ return 0
18
+ end
19
+ if %w[--help -h help].include?(command)
20
+ puts "Usage: jekyll-obsidian <init|build|dev|clean> [options]"
21
+ puts "Configuration: .github/jekyll-obsidian.yml"
22
+ puts "Run jekyll-obsidian COMMAND --help for command options."
23
+ return 0
24
+ end
25
+ raise ArgumentError, "unknown command: #{command}" unless %w[init build dev clean].include?(command)
26
+
27
+ options = {}
28
+ parser = OptionParser.new do |parser|
29
+ parser.banner = "Usage: jekyll-obsidian #{command} [options]"
30
+ if command == "init"
31
+ parser.on("--source PATH", "Content directory relative to the repository (default: docs)") { |value| options[:source] = value }
32
+ end
33
+ if %w[init build dev].include?(command)
34
+ parser.on("--theme minimal|docs", %w[minimal docs], "Site theme (default: configuration, or minimal)") { |value| options[:theme] = value }
35
+ end
36
+ if %w[build dev].include?(command)
37
+ parser.on("--baseurl PATH", "Site path, such as /project") { |value| options[:baseurl] = value }
38
+ end
39
+ if command == "build"
40
+ parser.on("--url ORIGIN", "Production origin, such as https://example.com") { |value| options[:url] = value }
41
+ end
42
+ if command == "dev"
43
+ parser.on("--host HOST", "Preview host (default: 127.0.0.1)") { |value| options[:host] = value }
44
+ parser.on("--port PORT", Integer, "Preview port (default: 58000)") do |value|
45
+ raise ArgumentError, "port must be between 1 and 65535" unless (1..65_535).cover?(value)
46
+ options[:port] = value
47
+ end
48
+ parser.separator "Watching is always enabled. Builds are complete and atomic."
49
+ parser.separator "Jekyll --watch and --incremental are not supported."
50
+ end
51
+ parser.on("-h", "--help", "Show this help") { puts parser; return 0 }
52
+ end
53
+ parser.parse!(arguments)
54
+ raise ArgumentError, "unexpected argument: #{arguments.first}" unless arguments.empty?
55
+
56
+ if command == "init"
57
+ created = Initializer.run(root: Dir.pwd, **options)
58
+ puts(created.empty? ? "Site files already exist." : "Created #{created.join(", ")}")
59
+ puts "Edit .github/jekyll-obsidian.yml, then push to GitHub or run jekyll-obsidian dev."
60
+ puts "For GitHub Pages, select Settings > Pages > Source > GitHub Actions."
61
+ return 0
62
+ end
63
+
64
+ runtime = Runtime.new(root: Runtime.discover_root)
65
+ case command
66
+ when "build"
67
+ puts "Built #{runtime.build(**options)}"
68
+ when "dev"
69
+ require_relative "preview"
70
+ Preview.new(runtime, **options).run
71
+ when "clean"
72
+ runtime.clean
73
+ puts "Build output is clean."
74
+ end
75
+ 0
76
+ rescue OptionParser::ParseError, ArgumentError, Runtime::Error, WorkspaceLayout::Invalid,
77
+ Jekyll::Errors::FatalException, SystemCallError => exception
78
+ warn "Error: #{exception.message}"
79
+ 1
80
+ rescue Interrupt
81
+ 130
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JekyllObsidian
4
+ # Owns the publication and content-classification rules for authored notes.
5
+ # Callers resolve configuration once, then use the same policy while selecting
6
+ # default-language notes, applying locale overlays, and classifying output.
7
+ class ContentPolicy
8
+ ROOT_DIRECTORY = "."
9
+ DEFAULT_SETTINGS = DeepFreeze.call(
10
+ "publish_by_default" => [],
11
+ "default_type" => "page",
12
+ "directories" => { "post" => ["blog"], "doc" => ["docs"] }
13
+ )
14
+ Resolution = ImmutableRecord.define(:policy, :diagnostics)
15
+
16
+ class << self
17
+ def resolve(raw)
18
+ diagnostics = []
19
+ settings = normalize(raw, diagnostics)
20
+ Resolution.new(policy: new(settings), diagnostics: diagnostics)
21
+ end
22
+
23
+ private
24
+
25
+ def normalize(raw, diagnostics)
26
+ unless raw.nil? || raw.is_a?(Hash)
27
+ diagnostics << diagnostic("invalid_content_config", "content must be a mapping")
28
+ return DEFAULT_SETTINGS
29
+ end
30
+ raw ||= {}
31
+ unknown = raw.keys.map(&:to_s) - %w[publish_by_default default_type directories]
32
+ unknown.sort.each do |key|
33
+ diagnostics << diagnostic("invalid_content_config", "unknown content setting #{key.inspect}")
34
+ end
35
+
36
+ default_type = fetch(raw, "default_type") || DEFAULT_SETTINGS.fetch("default_type")
37
+ unless FrontMatter::CONTENT_TYPES.include?(default_type)
38
+ diagnostics << diagnostic(
39
+ "invalid_content_config",
40
+ "content.default_type must be one of: #{FrontMatter::CONTENT_TYPES.join(', ')}"
41
+ )
42
+ default_type = DEFAULT_SETTINGS.fetch("default_type")
43
+ end
44
+
45
+ publish_by_default = normalize_publish_directories(raw, diagnostics)
46
+ directories = normalize_content_directories(raw, diagnostics)
47
+ directories.fetch("post").product(directories.fetch("doc")).each do |post_directory, doc_directory|
48
+ next unless directory_overlaps?(post_directory, doc_directory)
49
+
50
+ diagnostics << diagnostic(
51
+ "overlapping_content_directories",
52
+ "post and doc directories overlap: #{post_directory.inspect} and #{doc_directory.inspect}"
53
+ )
54
+ end
55
+
56
+ DeepFreeze.call(
57
+ "publish_by_default" => publish_by_default,
58
+ "default_type" => default_type,
59
+ "directories" => directories
60
+ )
61
+ end
62
+
63
+ def normalize_publish_directories(raw, diagnostics)
64
+ values = if raw.key?("publish_by_default") || raw.key?(:publish_by_default)
65
+ fetch(raw, "publish_by_default")
66
+ else
67
+ DEFAULT_SETTINGS.fetch("publish_by_default")
68
+ end
69
+ unless values.is_a?(Array)
70
+ diagnostics << diagnostic(
71
+ "invalid_publish_by_default",
72
+ "content.publish_by_default must be an array"
73
+ )
74
+ values = []
75
+ end
76
+ values.filter_map do |value|
77
+ normalize_directory(value, "content.publish_by_default", diagnostics, allow_root: true)
78
+ end.uniq.sort
79
+ end
80
+
81
+ def normalize_content_directories(raw, diagnostics)
82
+ directories = if raw.key?("directories") || raw.key?(:directories)
83
+ fetch(raw, "directories")
84
+ else
85
+ DEFAULT_SETTINGS.fetch("directories")
86
+ end
87
+ unless directories.is_a?(Hash)
88
+ diagnostics << diagnostic("invalid_content_directories", "content.directories must be a mapping")
89
+ directories = {}
90
+ end
91
+ unknown = directories.keys.map(&:to_s) - %w[post doc]
92
+ unknown.sort.each do |key|
93
+ diagnostics << diagnostic(
94
+ "invalid_content_directories",
95
+ "unknown content directory type #{key.inspect}"
96
+ )
97
+ end
98
+
99
+ %w[post doc].to_h do |type|
100
+ values = fetch(directories, type) || []
101
+ unless values.is_a?(Array)
102
+ diagnostics << diagnostic(
103
+ "invalid_content_directories",
104
+ "content.directories.#{type} must be an array"
105
+ )
106
+ values = []
107
+ end
108
+ normalized = values.filter_map do |value|
109
+ normalize_directory(value, "content.directories.#{type}", diagnostics, allow_root: false)
110
+ end.uniq.sort
111
+ [type, normalized]
112
+ end
113
+ end
114
+
115
+ def normalize_directory(value, setting, diagnostics, allow_root:)
116
+ unless FrontMatter.valid_output_text?(value)
117
+ diagnostics << diagnostic("invalid_content_directory", "#{setting} entries must be strings")
118
+ return nil
119
+ end
120
+ return ROOT_DIRECTORY if allow_root && value == ROOT_DIRECTORY
121
+
122
+ if value.empty? || value.start_with?("/", "\\") || value.include?("\\") || value != value.unicode_normalize(:nfc)
123
+ diagnostics << diagnostic(
124
+ "invalid_content_directory",
125
+ "#{setting} entries must be normalized vault-relative POSIX directories"
126
+ )
127
+ return nil
128
+ end
129
+ segments = value.split("/", -1)
130
+ if segments.any? { |segment| segment.empty? || segment == "." || segment == ".." }
131
+ diagnostics << diagnostic(
132
+ "invalid_content_directory",
133
+ "#{setting} entries must not contain empty or traversal segments"
134
+ )
135
+ return nil
136
+ end
137
+ value
138
+ rescue EncodingError
139
+ diagnostics << diagnostic("invalid_content_directory", "#{setting} entries must contain valid Unicode")
140
+ nil
141
+ end
142
+
143
+ def directory_overlaps?(first, second)
144
+ left = first.downcase(:fold)
145
+ right = second.downcase(:fold)
146
+ left == right || left.start_with?("#{right}/") || right.start_with?("#{left}/")
147
+ end
148
+
149
+ def fetch(hash, key)
150
+ hash.key?(key) ? hash[key] : hash[key.to_sym]
151
+ end
152
+
153
+ def diagnostic(code, message)
154
+ Diagnostic.new(severity: :error, code: code, message: message, path: nil, span: nil)
155
+ end
156
+ end
157
+
158
+ private_class_method :new
159
+
160
+ attr_reader :settings
161
+
162
+ def initialize(settings)
163
+ @settings = settings
164
+ @publish_by_default = settings.fetch("publish_by_default")
165
+ @directories = settings.fetch("directories")
166
+ @default_type = settings.fetch("default_type")
167
+ freeze
168
+ end
169
+
170
+ def publish?(path, properties)
171
+ return false if properties["publish"] == false
172
+ return true if properties["publish"] == true
173
+
174
+ @publish_by_default.any? { |directory| inside_directory?(path, directory) }
175
+ end
176
+
177
+ def classify(path, properties)
178
+ return "page" if path == "index.md"
179
+ return properties["content_type"] if properties["content_type"]
180
+
181
+ directory = File.dirname(path)
182
+ @directories.each do |type, configured|
183
+ return type if configured.any? { |prefix| directory == prefix || directory.start_with?("#{prefix}/") }
184
+ end
185
+ @default_type
186
+ end
187
+
188
+ private
189
+
190
+ def inside_directory?(path, directory)
191
+ directory == ROOT_DIRECTORY || path.start_with?("#{directory}/")
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JekyllObsidian
4
+ # Tracks normalized output destinations while treating a file and any path
5
+ # below that file as a collision. Each insertion visits only its path
6
+ # segments, so large sites do not compare every output with every other one.
7
+ class DestinationRegistry
8
+ Node = Struct.new(:owner, :descendant_owner, :children, keyword_init: true)
9
+
10
+ def initialize
11
+ @root = node
12
+ end
13
+
14
+ def add(destination, owner)
15
+ current = @root
16
+ lineage = [current]
17
+ destination.split("/").reject(&:empty?).each do |segment|
18
+ return current.owner if current.owner
19
+
20
+ current.children[segment] ||= node
21
+ current = current.children.fetch(segment)
22
+ lineage << current
23
+ end
24
+
25
+ return current.owner if current.owner
26
+ return current.descendant_owner if current.descendant_owner
27
+
28
+ current.owner = owner
29
+ lineage[0...-1].each { |ancestor| ancestor.descendant_owner ||= owner }
30
+ nil
31
+ end
32
+
33
+ private
34
+
35
+ def node
36
+ Node.new(owner: nil, descendant_owner: nil, children: {})
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JekyllObsidian
4
+ # Selects the one physical note path that may act as each directory's index.
5
+ # Publication is deliberately applied later: a private index.md still owns
6
+ # the physical slot and therefore prevents README.md from taking it over.
7
+ module DirectoryIndexes
8
+ PRIMARY_BASENAME = "index.md"
9
+ FALLBACK_BASENAME = "README.md"
10
+
11
+ module_function
12
+
13
+ def resolve(note_paths)
14
+ paths = Array(note_paths).map(&:to_s)
15
+ selected = {}
16
+
17
+ paths.each do |path|
18
+ selected[File.dirname(path)] = path if File.basename(path) == PRIMARY_BASENAME
19
+ end
20
+ paths.each do |path|
21
+ directory = File.dirname(path)
22
+ if File.basename(path) == FALLBACK_BASENAME && !selected.key?(directory)
23
+ selected[directory] = path
24
+ end
25
+ end
26
+
27
+ DeepFreeze.call(selected)
28
+ end
29
+ end
30
+ end