jekyll-client-search 0.1.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/CHANGELOG.md +121 -0
- data/LICENSE +22 -0
- data/NOTICE +49 -0
- data/README.developer.md +204 -0
- data/README.md +948 -0
- data/assets/adapters/elasticlunr.js +59 -0
- data/assets/adapters/minisearch.js +57 -0
- data/assets/adapters/semantic.js +154 -0
- data/assets/client-search-base.js +294 -0
- data/assets/client-search-related.js +176 -0
- data/assets/includes/related-articles.html +36 -0
- data/assets/layouts/post-with-related.html +54 -0
- data/assets/query-embedders/ollama-api.js +63 -0
- data/assets/query-embedders/transformers-worker.js +130 -0
- data/assets/query-embedders/transformers.js +223 -0
- data/docs/assets/icon-256.png +0 -0
- data/docs/assets/icon.svg +133 -0
- data/lib/jekyll/client_search/configuration.rb +152 -0
- data/lib/jekyll/client_search/configuration_accessors.rb +48 -0
- data/lib/jekyll/client_search/document_builder.rb +66 -0
- data/lib/jekyll/client_search/embedder_config_page.rb +14 -0
- data/lib/jekyll/client_search/embedding_configuration.rb +95 -0
- data/lib/jekyll/client_search/generator.rb +134 -0
- data/lib/jekyll/client_search/index_cache.rb +82 -0
- data/lib/jekyll/client_search/live_search_configuration.rb +70 -0
- data/lib/jekyll/client_search/ollama_embedding_adapter.rb +59 -0
- data/lib/jekyll/client_search/query_embedder_configuration.rb +127 -0
- data/lib/jekyll/client_search/related_analyzer.rb +152 -0
- data/lib/jekyll/client_search/related_configuration.rb +103 -0
- data/lib/jekyll/client_search/related_page.rb +14 -0
- data/lib/jekyll/client_search/related_tag.rb +76 -0
- data/lib/jekyll/client_search/runtime_config_page.rb +30 -0
- data/lib/jekyll/client_search/search_index_page.rb +15 -0
- data/lib/jekyll/client_search/search_tag.rb +100 -0
- data/lib/jekyll/client_search/tasks.rb +137 -0
- data/lib/jekyll/client_search/version.rb +7 -0
- data/lib/jekyll/client_search.rb +25 -0
- data/lib/jekyll-client-search.rb +3 -0
- metadata +104 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
<svg width="900" height="950" viewBox="0 0 900 950" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
|
|
3
|
+
<defs>
|
|
4
|
+
<style>
|
|
5
|
+
.dark { fill:#101826; }
|
|
6
|
+
.accent { fill:#e45842; }
|
|
7
|
+
.title {
|
|
8
|
+
font-family: Inter, Arial, Helvetica, sans-serif;
|
|
9
|
+
font-size: 88px;
|
|
10
|
+
font-weight: 700;
|
|
11
|
+
}
|
|
12
|
+
.subtitle {
|
|
13
|
+
font-family: Inter, Arial, Helvetica, sans-serif;
|
|
14
|
+
font-size: 32px;
|
|
15
|
+
fill: #6b7280;
|
|
16
|
+
letter-spacing: 3px;
|
|
17
|
+
}
|
|
18
|
+
</style>
|
|
19
|
+
</defs>
|
|
20
|
+
|
|
21
|
+
<!-- Main frame -->
|
|
22
|
+
<rect x="180"
|
|
23
|
+
y="40"
|
|
24
|
+
width="540"
|
|
25
|
+
height="540"
|
|
26
|
+
rx="45"
|
|
27
|
+
fill="white"
|
|
28
|
+
stroke="#101826"
|
|
29
|
+
stroke-width="16"/>
|
|
30
|
+
|
|
31
|
+
<!-- document -->
|
|
32
|
+
|
|
33
|
+
<rect x="280"
|
|
34
|
+
y="130"
|
|
35
|
+
width="250"
|
|
36
|
+
height="320"
|
|
37
|
+
rx="18"
|
|
38
|
+
fill="#101826"/>
|
|
39
|
+
|
|
40
|
+
<!-- folded corner -->
|
|
41
|
+
<polygon points="470,130 530,130 530,190"
|
|
42
|
+
class="accent"/>
|
|
43
|
+
|
|
44
|
+
<!-- text lines -->
|
|
45
|
+
<rect x="320" y="200" width="170" height="12" fill="white" opacity="0.9"/>
|
|
46
|
+
<rect x="320" y="245" width="140" height="12" fill="white" opacity="0.9"/>
|
|
47
|
+
<rect x="320" y="290" width="160" height="12" fill="white" opacity="0.9"/>
|
|
48
|
+
<rect x="320" y="335" width="120" height="12" fill="white" opacity="0.9"/>
|
|
49
|
+
|
|
50
|
+
<!-- accent highlight -->
|
|
51
|
+
<rect x="280"
|
|
52
|
+
y="395"
|
|
53
|
+
width="250"
|
|
54
|
+
height="55"
|
|
55
|
+
class="accent"/>
|
|
56
|
+
|
|
57
|
+
<!-- search badge -->
|
|
58
|
+
<g transform="translate(620,460)">
|
|
59
|
+
|
|
60
|
+
<circle r="120"
|
|
61
|
+
fill="#101826"
|
|
62
|
+
stroke="white"
|
|
63
|
+
stroke-width="8"/>
|
|
64
|
+
|
|
65
|
+
<!-- magnifier -->
|
|
66
|
+
<circle cx="-18"
|
|
67
|
+
cy="-18"
|
|
68
|
+
r="42"
|
|
69
|
+
fill="none"
|
|
70
|
+
stroke="white"
|
|
71
|
+
stroke-width="16"/>
|
|
72
|
+
|
|
73
|
+
<line x1="15"
|
|
74
|
+
y1="15"
|
|
75
|
+
x2="55"
|
|
76
|
+
y2="55"
|
|
77
|
+
stroke="white"
|
|
78
|
+
stroke-width="16"
|
|
79
|
+
stroke-linecap="round"/>
|
|
80
|
+
|
|
81
|
+
<!-- small accent dot -->
|
|
82
|
+
<circle cx="-70"
|
|
83
|
+
cy="-75"
|
|
84
|
+
r="10"
|
|
85
|
+
class="accent"/>
|
|
86
|
+
|
|
87
|
+
</g>
|
|
88
|
+
|
|
89
|
+
<!-- title -->
|
|
90
|
+
|
|
91
|
+
<text x="450"
|
|
92
|
+
y="720"
|
|
93
|
+
text-anchor="middle"
|
|
94
|
+
class="title">
|
|
95
|
+
|
|
96
|
+
<tspan fill="#101826">jekyll-client-</tspan>
|
|
97
|
+
<tspan fill="#e45842">search</tspan>
|
|
98
|
+
|
|
99
|
+
</text>
|
|
100
|
+
|
|
101
|
+
<!-- subtitle -->
|
|
102
|
+
|
|
103
|
+
<text x="450"
|
|
104
|
+
y="805"
|
|
105
|
+
text-anchor="middle"
|
|
106
|
+
class="subtitle">
|
|
107
|
+
|
|
108
|
+
FAST CLIENT-SIDE SEARCH FOR JEKYLL
|
|
109
|
+
|
|
110
|
+
</text>
|
|
111
|
+
|
|
112
|
+
<!-- decorative divider -->
|
|
113
|
+
|
|
114
|
+
<line x1="330"
|
|
115
|
+
y1="860"
|
|
116
|
+
x2="420"
|
|
117
|
+
y2="860"
|
|
118
|
+
stroke="#101826"
|
|
119
|
+
stroke-width="4"/>
|
|
120
|
+
|
|
121
|
+
<circle cx="450"
|
|
122
|
+
cy="860"
|
|
123
|
+
r="8"
|
|
124
|
+
class="accent"/>
|
|
125
|
+
|
|
126
|
+
<line x1="480"
|
|
127
|
+
y1="860"
|
|
128
|
+
x2="570"
|
|
129
|
+
y2="860"
|
|
130
|
+
stroke="#101826"
|
|
131
|
+
stroke-width="4"/>
|
|
132
|
+
|
|
133
|
+
</svg>
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jekyll
|
|
4
|
+
module ClientSearch
|
|
5
|
+
# Reads the +client_search+ section of the Jekyll site config and exposes
|
|
6
|
+
# validated, normalized values to the generator.
|
|
7
|
+
class Configuration
|
|
8
|
+
include ConfigurationAccessors
|
|
9
|
+
include EmbeddingConfiguration
|
|
10
|
+
|
|
11
|
+
ENGINES = %w[minisearch elasticlunr semantic].freeze
|
|
12
|
+
|
|
13
|
+
ENGINE_CDN_URLS = {
|
|
14
|
+
"minisearch" => "https://cdn.jsdelivr.net/npm/minisearch@7.2.0/dist/umd/index.min.js",
|
|
15
|
+
"elasticlunr" => "https://cdn.jsdelivr.net/npm/elasticlunr@0.9.5/elasticlunr.min.js",
|
|
16
|
+
"semantic" => nil
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
DEFAULTS = {
|
|
20
|
+
"enabled" => true,
|
|
21
|
+
"engine" => "minisearch",
|
|
22
|
+
"output" => "search-index.json",
|
|
23
|
+
"collections" => ["posts"],
|
|
24
|
+
"include_pages" => false,
|
|
25
|
+
"copy_runtime" => true,
|
|
26
|
+
"embedding" => {
|
|
27
|
+
"enabled" => false,
|
|
28
|
+
"model" => "embeddinggemma:300m",
|
|
29
|
+
"base_url" => "http://localhost:11434",
|
|
30
|
+
"connect_timeout" => 5,
|
|
31
|
+
"read_timeout" => 120,
|
|
32
|
+
"fail_on_error" => true,
|
|
33
|
+
"query_embedder" => {
|
|
34
|
+
"type" => "transformers"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
def initialize(site)
|
|
40
|
+
configured = site.config["client_search"]
|
|
41
|
+
configured = { "enabled" => false } if configured == false
|
|
42
|
+
configured ||= {}
|
|
43
|
+
unless configured.is_a?(Hash)
|
|
44
|
+
raise Jekyll::Errors::FatalException,
|
|
45
|
+
"client_search configuration must be a mapping or false"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
@values = DEFAULTS.merge(configured)
|
|
49
|
+
@live_search = LiveSearchConfiguration.new(configured["live_search"] || {}, engine: engine)
|
|
50
|
+
@related = RelatedConfiguration.new(configured["related"] || {})
|
|
51
|
+
merge_embedding_config(configured["embedding"])
|
|
52
|
+
validate_engine!
|
|
53
|
+
return unless embedding_enabled?
|
|
54
|
+
|
|
55
|
+
validate_embedding!
|
|
56
|
+
validate_query_embedder!
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def merge_embedding_config(configured_embedding)
|
|
62
|
+
embedding = configured_embedding || {}
|
|
63
|
+
unless embedding.is_a?(Hash)
|
|
64
|
+
raise Jekyll::Errors::FatalException,
|
|
65
|
+
"client_search embedding configuration must be a mapping"
|
|
66
|
+
end
|
|
67
|
+
@values["embedding"] = DEFAULTS.fetch("embedding").merge(embedding)
|
|
68
|
+
merge_query_embedder_config(embedding["query_embedder"])
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def merge_query_embedder_config(configured_query_embedder)
|
|
72
|
+
@query_embedder = QueryEmbedderConfiguration.new(
|
|
73
|
+
configured_query_embedder || {},
|
|
74
|
+
build_model: embedding_model,
|
|
75
|
+
build_base_url: embedding_base_url,
|
|
76
|
+
query_prefix: embedding_query_prefix
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
public
|
|
81
|
+
|
|
82
|
+
def enabled?
|
|
83
|
+
@values["enabled"] != false
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def engine
|
|
87
|
+
@values.fetch("engine").to_s
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def engine_url
|
|
91
|
+
@values.key?("engine_url") ? @values.fetch("engine_url") : ENGINE_CDN_URLS.fetch(engine)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def engine_sri
|
|
95
|
+
@values.fetch("engine_sri", nil)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def engine_crossorigin
|
|
99
|
+
@values.fetch("engine_crossorigin", nil)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def runtime_assets
|
|
103
|
+
assets = ["assets/client-search-base.js", "assets/adapters/#{engine}.js"]
|
|
104
|
+
assets.concat(query_embedder_assets) if engine == "semantic" && embedding_enabled?
|
|
105
|
+
assets << "assets/client-search-related.js" if related_enabled?
|
|
106
|
+
assets
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def output
|
|
110
|
+
normalize_output(@values.fetch("output"))
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def collections
|
|
114
|
+
Array(@values["collections"])
|
|
115
|
+
.compact
|
|
116
|
+
.map(&:to_s)
|
|
117
|
+
.reject(&:empty?)
|
|
118
|
+
.uniq
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def include_pages?
|
|
122
|
+
@values["include_pages"] == true
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def copy_runtime?
|
|
126
|
+
@values["copy_runtime"] != false
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def validate_query_embedder!
|
|
132
|
+
query_embedder_model if engine == "semantic" && query_embedder_type == "transformers"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def validate_engine!
|
|
136
|
+
return if ENGINES.include?(engine)
|
|
137
|
+
|
|
138
|
+
raise Jekyll::Errors::FatalException,
|
|
139
|
+
"client_search engine must be one of #{ENGINES.join(', ')} (got #{engine.inspect})"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def normalize_output(value)
|
|
143
|
+
output = value.to_s.sub(%r{\A/+}, "")
|
|
144
|
+
normalized = Pathname.new(output).cleanpath.to_s
|
|
145
|
+
return normalized unless output.empty? || normalized == "." || normalized.start_with?("../")
|
|
146
|
+
|
|
147
|
+
raise Jekyll::Errors::FatalException,
|
|
148
|
+
"client_search output must be a relative file path inside the destination"
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jekyll
|
|
4
|
+
module ClientSearch
|
|
5
|
+
# Exposes auxiliary generated-runtime settings through Configuration.
|
|
6
|
+
module ConfigurationAccessors
|
|
7
|
+
def live_search_config
|
|
8
|
+
@live_search.to_h(engine: engine)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def related_enabled?
|
|
12
|
+
@related.enabled?
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def related_output
|
|
16
|
+
@related.output
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def related_configuration
|
|
20
|
+
@related
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def query_embedder_type
|
|
24
|
+
@query_embedder.type
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def query_embedder_model
|
|
28
|
+
@query_embedder.model
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def query_embedder_api_url
|
|
32
|
+
@query_embedder.api_url
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def query_embedder_asset
|
|
36
|
+
@query_embedder.asset
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def query_embedder_assets
|
|
40
|
+
@query_embedder.assets
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def query_embedder_config_json
|
|
44
|
+
@query_embedder.to_json
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jekyll
|
|
4
|
+
module ClientSearch
|
|
5
|
+
# Normalizes Jekyll documents and pages into the flat hash shape that the
|
|
6
|
+
# search index JSON emits.
|
|
7
|
+
class DocumentBuilder
|
|
8
|
+
def from_document(document)
|
|
9
|
+
url = document.url.to_s
|
|
10
|
+
return if url.empty?
|
|
11
|
+
|
|
12
|
+
data = document.data
|
|
13
|
+
{
|
|
14
|
+
"id" => url,
|
|
15
|
+
"title" => clean(data["title"] || "Untitled"),
|
|
16
|
+
"url" => url,
|
|
17
|
+
"excerpt" => clean(data["excerpt"] || excerpt_for(document)),
|
|
18
|
+
"content" => clean(document.content),
|
|
19
|
+
"categories" => normalize_list(data["categories"]),
|
|
20
|
+
"tags" => normalize_list(data["tags"]),
|
|
21
|
+
"date" => normalized_date(document),
|
|
22
|
+
"date_timestamp" => normalized_timestamp(document)
|
|
23
|
+
}.compact
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def excerpt_for(document)
|
|
29
|
+
document.excerpt if document.respond_to?(:excerpt)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def normalize_list(value)
|
|
33
|
+
Array(value).compact.map { |item| clean(item) }.reject(&:empty?).uniq
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def normalized_date(document)
|
|
37
|
+
value = document.data["date"] || document.date if document.respond_to?(:date)
|
|
38
|
+
return if value.nil? || value.to_s.empty?
|
|
39
|
+
|
|
40
|
+
value.respond_to?(:iso8601) ? value.iso8601 : value.to_s
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def normalized_timestamp(document)
|
|
44
|
+
date = normalized_date(document)
|
|
45
|
+
date ? Time.iso8601(date).to_i : nil
|
|
46
|
+
rescue ArgumentError
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def clean(value)
|
|
51
|
+
cleaned = value.to_s
|
|
52
|
+
.gsub(%r{<script\b[^>]*>.*?</script>}mi, " ")
|
|
53
|
+
.gsub(%r{<style\b[^>]*>.*?</style>}mi, " ")
|
|
54
|
+
.gsub(/\{%.*?%\}/m, " ")
|
|
55
|
+
.gsub(/\{\{.*?\}\}/m, " ")
|
|
56
|
+
.gsub(/!\[[^\]]*\]\([^)]*\)/, " ")
|
|
57
|
+
.gsub(/<[^>]+>/, " ")
|
|
58
|
+
.gsub(/[`*_>#\[\]()]/, " ")
|
|
59
|
+
.gsub(/\s+/, " ")
|
|
60
|
+
.gsub(/\s+([,.!?;:])/, '\\1')
|
|
61
|
+
.strip
|
|
62
|
+
CGI.unescapeHTML(cleaned)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jekyll
|
|
4
|
+
module ClientSearch
|
|
5
|
+
# Generates the browser query-embedder configuration JavaScript.
|
|
6
|
+
class EmbedderConfigPage < Jekyll::PageWithoutAFile
|
|
7
|
+
def initialize(site, configuration)
|
|
8
|
+
super(site, site.source, "assets", "search-embedder-config.js")
|
|
9
|
+
self.data = { "layout" => nil, "sitemap" => false }
|
|
10
|
+
self.content = "window.ClientSearchEmbedderConfig = #{configuration.query_embedder_config_json};\n"
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jekyll
|
|
4
|
+
module ClientSearch
|
|
5
|
+
# Provides build-time embedding settings and model-specific prefixes.
|
|
6
|
+
module EmbeddingConfiguration
|
|
7
|
+
PREFIXES = {
|
|
8
|
+
"embeddinggemma" => {
|
|
9
|
+
"document" => "title: none | text: ",
|
|
10
|
+
"query" => "task: search result | query: "
|
|
11
|
+
},
|
|
12
|
+
"nomic-embed-text" => {
|
|
13
|
+
"document" => "search_document: ",
|
|
14
|
+
"query" => "search_query: "
|
|
15
|
+
}
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
def embedding_enabled?
|
|
19
|
+
@values.fetch("embedding").fetch("enabled") == true
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def embedding_model
|
|
23
|
+
@values.fetch("embedding").fetch("model").to_s
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def embedding_base_url
|
|
27
|
+
@values.fetch("embedding").fetch("base_url").to_s
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def embedding_connect_timeout
|
|
31
|
+
positive_timeout("connect_timeout")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def embedding_read_timeout
|
|
35
|
+
positive_timeout("read_timeout")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def embedding_fail_on_error?
|
|
39
|
+
@values.fetch("embedding").fetch("fail_on_error") != false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def embedding_include_in_index?
|
|
43
|
+
configured = @values.fetch("embedding")["include_in_index"]
|
|
44
|
+
configured.nil? ? engine == "semantic" : configured == true
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def embedding_document_prefix
|
|
48
|
+
configured_prefix("document_prefix", "document")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def embedding_query_prefix
|
|
52
|
+
configured_prefix("query_prefix", "query")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def embedding_identity
|
|
56
|
+
{
|
|
57
|
+
"provider" => "ollama",
|
|
58
|
+
"model" => embedding_model,
|
|
59
|
+
"base_url" => embedding_base_url,
|
|
60
|
+
"document_prefix" => embedding_document_prefix,
|
|
61
|
+
"query_prefix" => embedding_query_prefix,
|
|
62
|
+
"schema" => 2
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def configured_prefix(config_key, role)
|
|
69
|
+
embedding = @values.fetch("embedding")
|
|
70
|
+
return embedding.fetch(config_key).to_s if embedding.key?(config_key)
|
|
71
|
+
|
|
72
|
+
model_name = embedding_model.split(":").first
|
|
73
|
+
PREFIXES.fetch(model_name, {}).fetch(role, "")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def positive_timeout(key)
|
|
77
|
+
value = @values.fetch("embedding").fetch(key).to_f
|
|
78
|
+
unless value.positive? && value.finite?
|
|
79
|
+
raise Jekyll::Errors::FatalException,
|
|
80
|
+
"embedding #{key} must be a finite value greater than zero"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
value
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def validate_embedding!
|
|
87
|
+
raise Jekyll::Errors::FatalException, "embedding model must not be empty" if embedding_model.empty?
|
|
88
|
+
raise Jekyll::Errors::FatalException, "embedding base_url must not be empty" if embedding_base_url.empty?
|
|
89
|
+
|
|
90
|
+
embedding_connect_timeout
|
|
91
|
+
embedding_read_timeout
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jekyll
|
|
4
|
+
module ClientSearch
|
|
5
|
+
# Jekyll generator that emits the search index JSON and copies browser
|
|
6
|
+
# runtime assets (base + selected engine adapter) for client-side
|
|
7
|
+
# JavaScript search engines. Optionally enhances documents with
|
|
8
|
+
# embeddings from a local Ollama server, using a content-hash cache to
|
|
9
|
+
# avoid re-embedding unchanged documents.
|
|
10
|
+
class Generator < Jekyll::Generator
|
|
11
|
+
safe true
|
|
12
|
+
priority :low
|
|
13
|
+
|
|
14
|
+
def generate(site)
|
|
15
|
+
configuration = Configuration.new(site)
|
|
16
|
+
return unless configuration.enabled?
|
|
17
|
+
|
|
18
|
+
documents = build_documents(site, configuration)
|
|
19
|
+
documents = enhance_with_embeddings(site, documents, configuration) if configuration.embedding_enabled?
|
|
20
|
+
add_generated_pages(site, documents, configuration)
|
|
21
|
+
add_runtime_asset(site, configuration) if configuration.copy_runtime?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def add_generated_pages(site, documents, configuration)
|
|
27
|
+
add_related_page(site, documents, configuration) if configuration.related_enabled?
|
|
28
|
+
remove_embeddings(documents) unless configuration.embedding_include_in_index?
|
|
29
|
+
site.pages << SearchIndexPage.new(site, configuration.output, documents)
|
|
30
|
+
site.pages << RuntimeConfigPage.new(site, configuration) if configuration.copy_runtime?
|
|
31
|
+
add_embedder_config(site, configuration) if semantic_query_embedder?(configuration)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def build_documents(site, configuration)
|
|
35
|
+
builder = DocumentBuilder.new
|
|
36
|
+
documents = collection_documents(site, configuration, builder)
|
|
37
|
+
documents.concat(page_documents(site, builder, configuration)) if configuration.include_pages?
|
|
38
|
+
documents.compact.uniq { |document| document["id"] }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def enhance_with_embeddings(site, documents, configuration)
|
|
42
|
+
cache = IndexCache.new(site.source, embedding_identity: configuration.embedding_identity)
|
|
43
|
+
adapter = build_embedding_adapter(configuration)
|
|
44
|
+
|
|
45
|
+
documents.each { |entry| embed_document(entry, cache, adapter, configuration) }
|
|
46
|
+
cache.prune(documents.map { |doc| doc["id"] })
|
|
47
|
+
cache.save
|
|
48
|
+
documents
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def build_embedding_adapter(configuration)
|
|
52
|
+
OllamaEmbeddingAdapter.new(
|
|
53
|
+
model: configuration.embedding_model,
|
|
54
|
+
base_url: configuration.embedding_base_url,
|
|
55
|
+
connect_timeout: configuration.embedding_connect_timeout,
|
|
56
|
+
read_timeout: configuration.embedding_read_timeout
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def add_related_page(site, documents, configuration)
|
|
61
|
+
relation_data = RelatedAnalyzer.new(configuration.related_configuration).analyze(documents)
|
|
62
|
+
site.pages << RelatedPage.new(site, configuration.related_output, relation_data)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def remove_embeddings(documents)
|
|
66
|
+
documents.each { |document| document.delete("embedding") }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def embed_document(document, cache, adapter, configuration)
|
|
70
|
+
hash = IndexCache.content_hash(document)
|
|
71
|
+
cached = cache.lookup(document["id"], hash)
|
|
72
|
+
if cached && cached["embedding"]
|
|
73
|
+
document["embedding"] = cached["embedding"]
|
|
74
|
+
return
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
embedding = adapter.embed(embedding_text(document, configuration))
|
|
78
|
+
if embedding
|
|
79
|
+
document["embedding"] = embedding
|
|
80
|
+
cache.store(document["id"], hash, embedding)
|
|
81
|
+
elsif configuration.embedding_fail_on_error?
|
|
82
|
+
raise Jekyll::Errors::FatalException,
|
|
83
|
+
"embedding generation failed for document #{document['id'].inspect}"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def embedding_text(document, configuration)
|
|
88
|
+
text = [document["title"], document["excerpt"], document["content"]].compact.join(" ")
|
|
89
|
+
"#{configuration.embedding_document_prefix}#{text}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def collection_documents(site, configuration, builder)
|
|
93
|
+
configuration.collections.flat_map do |label|
|
|
94
|
+
collection = label == "posts" ? site.posts : site.collections[label]
|
|
95
|
+
next [] unless collection
|
|
96
|
+
|
|
97
|
+
collection.docs.filter_map { |document| builder.from_document(document) }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def page_documents(site, builder, configuration)
|
|
102
|
+
site.pages
|
|
103
|
+
.reject { |page| page.url == "/#{configuration.output}" }
|
|
104
|
+
.select { |page| page.data["title"] }
|
|
105
|
+
.filter_map { |page| builder.from_document(page) }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def add_runtime_asset(site, configuration)
|
|
109
|
+
root = File.expand_path("../../..", __dir__)
|
|
110
|
+
configuration.runtime_assets.each do |asset|
|
|
111
|
+
relative = asset.sub(%r{\A/}, "")
|
|
112
|
+
next if site.static_files.any? { |file| normalized_path(file.relative_path) == relative }
|
|
113
|
+
|
|
114
|
+
base = File.dirname(asset)
|
|
115
|
+
name = File.basename(asset)
|
|
116
|
+
site.static_files << Jekyll::StaticFile.new(site, root, base, name)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def add_embedder_config(site, configuration)
|
|
121
|
+
site.pages << EmbedderConfigPage.new(site, configuration)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def semantic_query_embedder?(configuration)
|
|
125
|
+
configuration.engine == "semantic" && configuration.embedding_enabled? &&
|
|
126
|
+
configuration.query_embedder_type != "none"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def normalized_path(path)
|
|
130
|
+
path.to_s.sub(%r{\A/}, "")
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|