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,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JekyllObsidian
4
+ module MediaPolicy
5
+ TYPES = {
6
+ ".3gp" => [:audio, "audio/3gpp"],
7
+ ".apng" => [:image, "image/apng"],
8
+ ".avif" => [:image, "image/avif"],
9
+ ".base" => [:download, "application/json"],
10
+ ".bmp" => [:image, "image/bmp"],
11
+ ".canvas" => [:download, "application/json"],
12
+ ".flac" => [:audio, "audio/flac"],
13
+ ".gif" => [:image, "image/gif"],
14
+ ".jpeg" => [:image, "image/jpeg"],
15
+ ".jpg" => [:image, "image/jpeg"],
16
+ ".m4a" => [:audio, "audio/mp4"],
17
+ ".mkv" => [:video, "video/x-matroska"],
18
+ ".mov" => [:video, "video/quicktime"],
19
+ ".mp3" => [:audio, "audio/mpeg"],
20
+ ".mp4" => [:video, "video/mp4"],
21
+ ".ogg" => [:audio, "audio/ogg"],
22
+ ".ogv" => [:video, "video/ogg"],
23
+ ".pdf" => [:pdf, "application/pdf"],
24
+ ".png" => [:image, "image/png"],
25
+ ".svg" => [:image, "image/svg+xml"],
26
+ ".wav" => [:audio, "audio/wav"],
27
+ ".webm" => [:video, "video/webm"],
28
+ ".webp" => [:image, "image/webp"]
29
+ }.transform_values(&:freeze).freeze
30
+
31
+ module_function
32
+
33
+ def kind(path)
34
+ TYPES.dig(File.extname(path).downcase, 0)
35
+ end
36
+
37
+ def media_type(path, fallback: "application/octet-stream")
38
+ TYPES.dig(File.extname(path).downcase, 1) || fallback
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,493 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi/escape"
4
+
5
+ module JekyllObsidian
6
+ class OfmScanner
7
+ Embed = Struct.new(:target, :source_span, :token, keyword_init: true)
8
+ Iframe = Struct.new(:html, :source_span, :token, :closed, keyword_init: true)
9
+ Wikilink = Struct.new(:target, :display, :source_span, :token, keyword_init: true)
10
+ Result = Struct.new(:markdown, :block_ids, :embeds, :iframes, :wikilinks, :tags, keyword_init: true)
11
+
12
+ VOID_HTML_TAGS = %w[
13
+ area base basefont bgsound br col command embed frame hr img input keygen
14
+ link menuitem meta param source track wbr
15
+ ].freeze
16
+
17
+ def self.prepare(markdown)
18
+ new(markdown).prepare
19
+ end
20
+
21
+ def initialize(markdown)
22
+ @markdown = markdown
23
+ @ofm_comment = false
24
+ @html_comment = false
25
+ @fence = nil
26
+ @inline_code_delimiter = nil
27
+ @raw_html_stack = []
28
+ @html_tag = nil
29
+ @iframe_body = nil
30
+ @list_quote_depth = nil
31
+ @list_indents = []
32
+ @block_ids = []
33
+ @embeds = []
34
+ @iframes = []
35
+ @wikilinks = []
36
+ @tags = []
37
+ end
38
+
39
+ def prepare
40
+ output = @markdown.lines.map.with_index(1) { |line, line_number| process_line(line, line_number) }.join
41
+ capture_unfinished_iframe
42
+ Result.new(
43
+ markdown: output,
44
+ block_ids: @block_ids,
45
+ embeds: @embeds,
46
+ iframes: @iframes,
47
+ wikilinks: @wikilinks,
48
+ tags: @tags.uniq
49
+ )
50
+ end
51
+
52
+ private
53
+
54
+ def process_line(line, line_number)
55
+ if @fence
56
+ content = fenced_container_content(line, @fence)
57
+ unless content
58
+ @fence = nil
59
+ return process_line(line, line_number)
60
+ end
61
+
62
+ @fence = nil if fence_closer?(content, @fence)
63
+ return line
64
+ end
65
+
66
+ if plain_markdown_state?
67
+ context = markdown_context(line)
68
+ if (opener = fence_opener(context.fetch(:content)))
69
+ @fence = opener.merge(
70
+ quote_depth: context.fetch(:quote_depth),
71
+ list_indent: context.fetch(:list_indent)
72
+ )
73
+ return line
74
+ end
75
+
76
+ # Indented code begins four columns beyond the surrounding blockquote
77
+ # and list content baseline, not necessarily four columns from column
78
+ # zero. This distinction keeps ordinary list paragraphs scannable.
79
+ return line if indented_code_line?(context.fetch(:content))
80
+ end
81
+
82
+ scan_line(line, line_number)
83
+ end
84
+
85
+ def scan_line(line, line_number)
86
+ output = +""
87
+ cursor = 0
88
+
89
+ while cursor < line.length
90
+ if @iframe_body
91
+ cursor = continue_iframe_body(line, cursor, output)
92
+ elsif @html_tag
93
+ cursor = continue_html_tag(line, cursor, output, line_number)
94
+ elsif @html_comment
95
+ cursor = continue_comment(line, cursor, output, "-->", :@html_comment)
96
+ elsif @ofm_comment
97
+ cursor = continue_comment(line, cursor, output, "%%", :@ofm_comment)
98
+ elsif @inline_code_delimiter
99
+ cursor = continue_inline_code(line, cursor, output)
100
+ elsif line[cursor, 4] == "<!--"
101
+ @html_comment = true
102
+ cursor += 4
103
+ elsif line[cursor, 2] == "%%"
104
+ @ofm_comment = true
105
+ cursor += 2
106
+ elsif line[cursor] == "`" && @raw_html_stack.empty?
107
+ run = backtick_run(line, cursor)
108
+ @inline_code_delimiter = run
109
+ output << run
110
+ cursor += run.length
111
+ elsif line[cursor] == "\\" && @raw_html_stack.empty? && cursor + 1 < line.length
112
+ if line[cursor + 1] == "!" && line[cursor + 2, 2] == "[["
113
+ # Commonmarker otherwise parses the wikilink after the escaped
114
+ # exclamation mark. Escape its first bracket as well so the whole
115
+ # authored sequence remains literal and cannot publish a target.
116
+ output << "\\!\\["
117
+ cursor += 3
118
+ else
119
+ output << line[cursor, 2]
120
+ cursor += 2
121
+ end
122
+ elsif line[cursor] == "<" && (tag = html_tag_start(line, cursor))
123
+ cursor = start_html_tag(line, cursor, output, tag, line_number)
124
+ elsif !@raw_html_stack.empty?
125
+ output << line[cursor]
126
+ cursor += 1
127
+ elsif line[cursor, 3] == "![["
128
+ cursor = scan_embed(line, cursor, line_number, output)
129
+ elsif line[cursor, 2] == "[["
130
+ cursor = scan_wikilink(line, cursor, line_number, output)
131
+ elsif line[cursor] == "#"
132
+ cursor = scan_tag(line, cursor, output)
133
+ elsif line[cursor] == "^" && block_id_boundary?(line, cursor) && (match = line[cursor..].match(/\A\^([\p{L}\p{N}_-]+)[ \t]*(\r?\n)?\z/u))
134
+ block_id = match[1].unicode_normalize(:nfc)
135
+ @block_ids << [block_id, line_number]
136
+ output << %(<span data-website-block-id="#{block_id}"></span>)
137
+ output << match[2].to_s
138
+ cursor = line.length
139
+ else
140
+ output << line[cursor]
141
+ cursor += 1
142
+ end
143
+ end
144
+
145
+ # Removing a whole-line comment must not join the surrounding Markdown
146
+ # lines into one paragraph token.
147
+ output << line_ending(line) if output.empty? && !line_ending(line).empty?
148
+ output
149
+ end
150
+
151
+ def continue_comment(line, cursor, output, delimiter, state)
152
+ ending = line.index(delimiter, cursor)
153
+ unless ending
154
+ ending = line_ending(line)
155
+ output << ending unless ending.empty? || output.end_with?(ending)
156
+ return line.length
157
+ end
158
+
159
+ instance_variable_set(state, false)
160
+ ending + delimiter.length
161
+ end
162
+
163
+ def continue_inline_code(line, cursor, output)
164
+ if line[cursor] == "`"
165
+ run = backtick_run(line, cursor)
166
+ if run.length == @inline_code_delimiter.length
167
+ output << run
168
+ @inline_code_delimiter = nil
169
+ return cursor + run.length
170
+ end
171
+ end
172
+
173
+ output << line[cursor]
174
+ cursor + 1
175
+ end
176
+
177
+ def scan_embed(line, cursor, line_number, output)
178
+ ending = line.index("]]", cursor + 3)
179
+ unless ending
180
+ output << line[cursor]
181
+ return cursor + 1
182
+ end
183
+
184
+ target = line[(cursor + 3)...ending]
185
+ token = @embeds.length
186
+ @embeds << Embed.new(
187
+ target: target,
188
+ source_span: SourceSpan.new(
189
+ start_line: line_number,
190
+ start_column: cursor + 1,
191
+ end_line: line_number,
192
+ end_column: ending + 2
193
+ ),
194
+ token: token
195
+ )
196
+ output << %(<website-ofm-embed data-token="#{token}"></website-ofm-embed>)
197
+ ending + 2
198
+ end
199
+
200
+ def scan_wikilink(line, cursor, line_number, output)
201
+ ending = line.index("]]", cursor + 2)
202
+ unless ending
203
+ output << line[cursor]
204
+ return cursor + 1
205
+ end
206
+
207
+ inner = line[(cursor + 2)...ending]
208
+ target, display = inner.split("|", 2)
209
+ token = @wikilinks.length
210
+ @wikilinks << Wikilink.new(
211
+ target: target.to_s,
212
+ display: display,
213
+ source_span: SourceSpan.new(
214
+ start_line: line_number,
215
+ start_column: cursor + 1,
216
+ end_line: line_number,
217
+ end_column: ending + 2
218
+ ),
219
+ token: token
220
+ )
221
+ label = display || target
222
+ output << %(<a data-website-wikilink-token="#{token}">#{CGI.escapeHTML(label.to_s)}</a>)
223
+ ending + 2
224
+ end
225
+
226
+ def scan_tag(line, cursor, output)
227
+ match = tag_boundary?(line, cursor) && line[cursor..].match(/\A#([\p{L}\p{N}_-]+(?:\/[\p{L}\p{N}_-]+)*)/u)
228
+ if match && !match[1].match?(/\A\d+\z/)
229
+ @tags << match[1].unicode_normalize(:nfc)
230
+ output << match[0]
231
+ cursor + match[0].length
232
+ else
233
+ output << line[cursor]
234
+ cursor + 1
235
+ end
236
+ end
237
+
238
+ def fence_opener(content)
239
+ match = content.match(/^ {0,3}(`{3,}|~{3,})/)
240
+ return nil unless match
241
+
242
+ { character: match[1][0], length: match[1].length }
243
+ end
244
+
245
+ def fence_closer?(content, fence)
246
+ match = content.match(/^ {0,3}([#{Regexp.escape(fence.fetch(:character))}]+)[ \t]*(?:\r?\n)?$/)
247
+ match && match[1].length >= fence.fetch(:length)
248
+ end
249
+
250
+ def indented_code_line?(content)
251
+ content.match?(/\A(?: {4}|\t)/) && !content.strip.empty?
252
+ end
253
+
254
+ def markdown_context(line)
255
+ content, quote_depth = strip_blockquote_prefixes(line)
256
+ if @list_quote_depth != quote_depth
257
+ @list_quote_depth = quote_depth
258
+ @list_indents = []
259
+ end
260
+
261
+ unless content.strip.empty?
262
+ leading_columns = indentation_columns(content)
263
+ @list_indents.pop while @list_indents.last && @list_indents.last > leading_columns
264
+ end
265
+
266
+ baseline = @list_indents.last || 0
267
+ effective, = remove_indentation(content, baseline)
268
+ if (item = list_item_context(effective))
269
+ item_indent = baseline + item.fetch(:content_indent)
270
+ @list_indents << item_indent
271
+ effective = item.fetch(:content)
272
+ baseline = item_indent
273
+ end
274
+
275
+ { content: effective, quote_depth: quote_depth, list_indent: baseline }
276
+ end
277
+
278
+ def fenced_container_content(line, fence)
279
+ content, quote_depth = strip_blockquote_prefixes(line)
280
+ return content if content.strip.empty?
281
+ return nil unless quote_depth == fence.fetch(:quote_depth)
282
+
283
+ normalized, satisfied = remove_indentation(content, fence.fetch(:list_indent))
284
+ satisfied ? normalized : nil
285
+ end
286
+
287
+ def strip_blockquote_prefixes(line)
288
+ cursor = 0
289
+ depth = 0
290
+ loop do
291
+ start = cursor
292
+ spaces = 0
293
+ while spaces < 3 && line[cursor] == " "
294
+ spaces += 1
295
+ cursor += 1
296
+ end
297
+ unless line[cursor] == ">"
298
+ cursor = start
299
+ break
300
+ end
301
+
302
+ depth += 1
303
+ cursor += 1
304
+ cursor += 1 if line[cursor] == " " || line[cursor] == "\t"
305
+ end
306
+ [line[cursor..].to_s, depth]
307
+ end
308
+
309
+ def list_item_context(content)
310
+ ending = line_ending(content)
311
+ body = ending.empty? ? content : content.delete_suffix(ending)
312
+ match = body.match(/\A( {0,3})([-+*]|\d{1,9}[.)])([ \t]{1,4})(.*)\z/m)
313
+ return nil unless match
314
+
315
+ content_indent = indentation_columns(match[1]) + match[2].length
316
+ match[3].each_char do |character|
317
+ content_indent += character == "\t" ? 4 - (content_indent % 4) : 1
318
+ end
319
+ {
320
+ content: "#{match[4]}#{ending}",
321
+ content_indent: content_indent
322
+ }
323
+ end
324
+
325
+ def indentation_columns(content)
326
+ columns = 0
327
+ content.each_char do |character|
328
+ case character
329
+ when " "
330
+ columns += 1
331
+ when "\t"
332
+ columns += 4 - (columns % 4)
333
+ else
334
+ break
335
+ end
336
+ end
337
+ columns
338
+ end
339
+
340
+ def remove_indentation(content, columns)
341
+ return [content, true] if columns.zero?
342
+
343
+ consumed_columns = 0
344
+ cursor = 0
345
+ while cursor < content.length && consumed_columns < columns
346
+ character = content[cursor]
347
+ width = if character == " "
348
+ 1
349
+ elsif character == "\t"
350
+ 4 - (consumed_columns % 4)
351
+ else
352
+ return [content, false]
353
+ end
354
+ consumed_columns += width
355
+ cursor += 1
356
+ end
357
+ return [content, false] if consumed_columns < columns
358
+
359
+ ["#{" " * (consumed_columns - columns)}#{content[cursor..]}", true]
360
+ end
361
+
362
+ def html_tag_start(line, cursor)
363
+ match = line[cursor..].match(/\A<\s*(\/)?\s*([A-Za-z][A-Za-z0-9-]*)(?=[\s\/>])/)
364
+ return nil unless match
365
+
366
+ { name: match[2].downcase, closing: !match[1].nil? }
367
+ end
368
+
369
+ def start_html_tag(line, cursor, output, tag, line_number)
370
+ capture_iframe = tag[:name] == "iframe" && !tag[:closing]
371
+ @html_tag = tag.merge(
372
+ quote: nil,
373
+ last_nonspace: nil,
374
+ capture_iframe: capture_iframe,
375
+ raw: capture_iframe ? +"" : nil,
376
+ start_line: line_number,
377
+ start_column: cursor + 1,
378
+ end_line: line_number,
379
+ end_column: cursor + 1
380
+ )
381
+ continue_html_tag(line, cursor, output, line_number)
382
+ end
383
+
384
+ def continue_html_tag(line, cursor, output, line_number)
385
+ while cursor < line.length
386
+ character = line[cursor]
387
+ if @html_tag[:capture_iframe]
388
+ @html_tag[:raw] << character
389
+ @html_tag[:end_line] = line_number
390
+ @html_tag[:end_column] = cursor + 1
391
+ else
392
+ output << character
393
+ end
394
+ if @html_tag[:quote]
395
+ @html_tag[:quote] = nil if character == @html_tag[:quote]
396
+ elsif character == '"' || character == "'"
397
+ @html_tag[:quote] = character
398
+ elsif character == ">"
399
+ captured = @html_tag[:capture_iframe]
400
+ self_closing = @html_tag[:last_nonspace] == "/"
401
+ tag = @html_tag
402
+ finish_html_tag
403
+ if captured
404
+ iframe = Iframe.new(
405
+ html: tag.fetch(:raw),
406
+ source_span: SourceSpan.new(
407
+ start_line: tag.fetch(:start_line),
408
+ start_column: tag.fetch(:start_column),
409
+ end_line: line_number,
410
+ end_column: cursor + 1
411
+ ),
412
+ token: @iframes.length,
413
+ closed: self_closing
414
+ )
415
+ @iframes << iframe
416
+ output << %(<website-ofm-iframe data-token="#{iframe.token}"></website-ofm-iframe>)
417
+ @iframe_body = iframe unless self_closing
418
+ end
419
+ return cursor + 1
420
+ elsif !character.match?(/\s/)
421
+ @html_tag[:last_nonspace] = character
422
+ end
423
+ cursor += 1
424
+ end
425
+ if @html_tag[:capture_iframe]
426
+ ending = line_ending(line)
427
+ output << ending unless ending.empty? || output.end_with?(ending)
428
+ end
429
+ cursor
430
+ end
431
+
432
+ def capture_unfinished_iframe
433
+ tag = @html_tag
434
+ return unless tag&.fetch(:capture_iframe, false)
435
+
436
+ @iframes << Iframe.new(
437
+ html: tag.fetch(:raw),
438
+ source_span: SourceSpan.new(
439
+ start_line: tag.fetch(:start_line),
440
+ start_column: tag.fetch(:start_column),
441
+ end_line: tag.fetch(:end_line),
442
+ end_column: tag.fetch(:end_column)
443
+ ),
444
+ token: @iframes.length,
445
+ closed: false
446
+ )
447
+ end
448
+
449
+ def continue_iframe_body(line, cursor, output)
450
+ match = line.match(%r{</\s*iframe\s*>}i, cursor)
451
+ unless match
452
+ ending = line_ending(line)
453
+ output << ending unless ending.empty? || output.end_with?(ending)
454
+ return line.length
455
+ end
456
+
457
+ @iframe_body.closed = true
458
+ @iframe_body = nil
459
+ match.end(0)
460
+ end
461
+
462
+ def finish_html_tag
463
+ tag = @html_tag
464
+ if tag[:closing]
465
+ matching = @raw_html_stack.rindex(tag[:name])
466
+ @raw_html_stack.slice!(matching..) if matching
467
+ elsif !tag[:capture_iframe] && tag[:last_nonspace] != "/" && !VOID_HTML_TAGS.include?(tag[:name])
468
+ @raw_html_stack << tag[:name]
469
+ end
470
+ @html_tag = nil
471
+ end
472
+
473
+ def plain_markdown_state?
474
+ !@ofm_comment && !@html_comment && !@inline_code_delimiter && @raw_html_stack.empty? && !@html_tag
475
+ end
476
+
477
+ def backtick_run(line, cursor)
478
+ line[cursor..].match(/\A`+/)[0]
479
+ end
480
+
481
+ def tag_boundary?(line, cursor)
482
+ cursor.zero? || !line[cursor - 1].match?(/[\p{L}\p{N}_\/-]/u)
483
+ end
484
+
485
+ def block_id_boundary?(line, cursor)
486
+ cursor.zero? || line[cursor - 1].match?(/\s/)
487
+ end
488
+
489
+ def line_ending(line)
490
+ line[/\r?\n\z/].to_s
491
+ end
492
+ end
493
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JekyllObsidian
4
+ module OutputText
5
+ INVALID_CHARACTER = /[^\u{9}\u{A}\u{D}\u{20}-\u{D7FF}\u{E000}-\u{FFFD}\u{10000}-\u{10FFFF}]/u
6
+
7
+ module_function
8
+
9
+ def valid?(value)
10
+ value.is_a?(String) && value.valid_encoding? && !value.match?(INVALID_CHARACTER)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "listen"
4
+ require "webrick"
5
+ require_relative "runtime"
6
+
7
+ module JekyllObsidian
8
+ class Preview
9
+ CONTENT_IGNORE = %r{\A(?:\.obsidian|\.trash)(?:/|\z)}
10
+
11
+ def initialize(runtime, host: "127.0.0.1", port: 58_000, baseurl: "", theme: nil, output: $stdout, warnings: $stderr)
12
+ @runtime = runtime
13
+ @host, @port, @baseurl, @theme = host, port, baseurl.delete_suffix("/"), theme
14
+ @output, @warnings = output, warnings
15
+ @events = Queue.new
16
+ @stopping = false
17
+ end
18
+
19
+ def run
20
+ origin = "http://#{@host.include?(":") ? "[#{@host}]" : @host}:#{@port}"
21
+ server = WEBrick::HTTPServer.new(
22
+ BindAddress: @host, Port: @port,
23
+ AccessLog: [], Logger: WEBrick::Log.new(@warnings, WEBrick::Log::WARN),
24
+ MimeTypes: WEBrick::HTTPUtils::DefaultMimeTypes.merge("md" => "text/markdown", "js" => "text/javascript")
25
+ )
26
+ server.mount(@baseurl.empty? ? "/" : @baseurl, WEBrick::HTTPServlet::FileHandler,
27
+ @runtime.destination, FancyIndexing: false)
28
+ previous_signals = %w[INT TERM].to_h { |signal| [signal, Signal.trap(signal) { @stopping = true }] }
29
+ config_listener = Listen.to(File.dirname(@runtime.config_path)) do |modified, added, removed|
30
+ if (modified + added + removed).include?(@runtime.config_path)
31
+ @events << :config
32
+ end
33
+ end.tap(&:start)
34
+ refresh_content_listener
35
+ rebuild(origin, initial: true)
36
+ server_thread = Thread.new { server.start }
37
+ @output.puts "Serving #{origin}#{@baseurl}/"
38
+ @output.flush
39
+ until @stopping
40
+ raise Runtime::Error, "preview server stopped" unless server_thread.alive?
41
+ event = @events.pop(timeout: 0.25)
42
+ unless @content_listener
43
+ refresh_content_listener
44
+ event ||= :content if @content_listener
45
+ end
46
+ next unless event
47
+
48
+ # Coalesce editor writes without allowing a steady event stream to starve a build.
49
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 0.25
50
+ while (remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)).positive?
51
+ break unless @events.pop(timeout: remaining)
52
+ end
53
+ refresh_content_listener
54
+ rebuild(origin, initial: false)
55
+ end
56
+ ensure
57
+ config_listener&.stop
58
+ @content_listener&.stop
59
+ server&.shutdown
60
+ server_thread&.join
61
+ previous_signals&.each { |signal, handler| Signal.trap(signal, handler) }
62
+ end
63
+
64
+ private
65
+
66
+ def refresh_content_listener
67
+ source_root = @runtime.source_root
68
+ return if source_root == @watched_root
69
+
70
+ next_listener = Listen.to(source_root, ignore: CONTENT_IGNORE) { @events << :content }.tap(&:start)
71
+ @content_listener&.stop
72
+ @content_listener = next_listener
73
+ @watched_root = source_root
74
+ @output.puts "Watching #{Pathname.new(source_root).relative_path_from(Pathname.new(@runtime.root))}/"
75
+ rescue Runtime::Error, WorkspaceLayout::Invalid, SystemCallError
76
+ # Keep the previous valid listener while the configuration is being repaired.
77
+ end
78
+
79
+ def rebuild(origin, initial:)
80
+ @output.puts(initial ? "Building site..." : "Source changed. Rebuilding site...")
81
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
82
+ @runtime.build(url: origin, baseurl: @baseurl, theme: @theme, environment: "development", quiet: true)
83
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
84
+ @output.puts format("Built site in %.2fs.", elapsed)
85
+ rescue Runtime::Error, WorkspaceLayout::Invalid, Jekyll::Errors::FatalException, SystemCallError => exception
86
+ @warnings.puts exception.message
87
+ @warnings.puts(initial ? "Initial build failed. Watching for repairs." : "Build failed. Continuing to serve the last successful site.")
88
+ ensure
89
+ @output.flush
90
+ @warnings.flush
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JekyllObsidian
4
+ # Owns the one plain-Markdown representation shared by generated resources
5
+ # and the page actions that copy them. Callers only provide authored source
6
+ # facts; route and byte normalization stay behind this interface.
7
+ module PublishedMarkdown
8
+ ESCAPABLE_PUNCTUATION = /([!"#$%&'()*+,\-.\/:;<=>?@\[\\\]^_`{|}~])/
9
+ TRAILING_NEWLINES = /(?:\r\n|\n|\r)+\z/
10
+ module_function
11
+
12
+ def content(title:, body:, has_h1:)
13
+ authored = body.to_s.sub(TRAILING_NEWLINES, "")
14
+ return "#{authored}\n" if has_h1
15
+
16
+ heading = "# #{escape_heading(title)}"
17
+ authored.empty? ? "#{heading}\n" : "#{heading}\n\n#{authored}\n"
18
+ end
19
+
20
+ def route(page_route)
21
+ return "/index.md" if page_route == "/"
22
+ return "#{page_route.delete_suffix('/')}.md" if page_route.end_with?("/")
23
+
24
+ "#{page_route}.md"
25
+ end
26
+
27
+ def escape_heading(title)
28
+ title.to_s.gsub(/[\r\n]+/, " ").gsub(ESCAPABLE_PUNCTUATION, '\\\\\1')
29
+ end
30
+ private_class_method :escape_heading
31
+ end
32
+ end