simple-dynamsoft-mcp 7.6.1 → 7.7.1

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.
@@ -1,5 +1,5 @@
1
1
  import Fuse from "fuse.js";
2
- import { expandQueryTokens, extractSnippet } from "./search-utils.js";
2
+ import { chunkMarkdown, expandQueryTokens, extractSnippet } from "./search-utils.js";
3
3
 
4
4
  const DEFAULT_FUSE_OPTIONS = {
5
5
  keys: [
@@ -12,27 +12,40 @@ const DEFAULT_FUSE_OPTIONS = {
12
12
  includeScore: true
13
13
  };
14
14
 
15
- const BODY_INDEX_CHARS = 4000;
15
+ // Section size and overlap for body scoring; match the embedding chunking so
16
+ // lexical and Gemini see the same units, and so a term cut at a chunk boundary
17
+ // still appears whole in the neighbouring chunk.
18
+ const SECTION_CHARS = 1200;
19
+ const SECTION_OVERLAP_CHARS = 200;
16
20
 
17
- // Field-weighted BM25 haystack: title/tags outrank summary; doc/sample body text
18
- // is indexed at low weight so body-only content is findable; the URI's last slug
21
+ // Field-weighted header: title/tags outrank summary; the URI's last slug
19
22
  // segment is kept (it is often the most descriptive token) while scheme/version
20
23
  // noise is dropped (issue #147).
21
- function buildLexicalHaystack(entry) {
24
+ function buildHeaderText(entry) {
22
25
  const title = entry.title || "";
23
26
  const tags = Array.isArray(entry.tags) ? entry.tags.join(" ") : "";
24
27
  const summary = entry.summary || "";
25
- const body = entry.embedText ? String(entry.embedText).slice(0, BODY_INDEX_CHARS) : "";
26
28
  const uriSlug = String(entry.uri || "").split("/").filter(Boolean).pop() || "";
27
- return [
28
- title, title, title,
29
- tags, tags,
30
- summary,
31
- uriSlug,
32
- body
33
- ].join(" \n ");
29
+ return [title, title, title, tags, tags, summary, uriSlug].join(" \n ");
34
30
  }
35
31
 
32
+ // The whole body, split on headings (each section keeps its heading path), so
33
+ // content deep in long guides is searchable and a page is ranked by its best
34
+ // matching section instead of by one length-diluted blob.
35
+ function buildBodySections(entry) {
36
+ if (!entry.embedText) return [];
37
+ return chunkMarkdown(entry.embedText, SECTION_CHARS, SECTION_OVERLAP_CHARS, 0);
38
+ }
39
+
40
+ const MAX_SCOPED_FUSE_INDEXES = 64;
41
+
42
+ // Fuse matches long natural-language questions only through fragments of the
43
+ // pattern, giving a weak, noisy signal at a cost of seconds on large scopes.
44
+ // On the golden set (scripts/eval-retrieval.mjs), skipping it above this length
45
+ // kept recall, raised MRR, and cut warm p95 from ~2.2s to ~0.2s. BM25 carries
46
+ // long queries.
47
+ const MAX_FUSE_QUERY_CHARS = 64;
48
+
36
49
  const BM25_K1 = 1.2;
37
50
  const BM25_B = 0.75;
38
51
 
@@ -61,63 +74,86 @@ function compareLexicalResults(a, b) {
61
74
  return String(a.entry.uri || "").localeCompare(String(b.entry.uri || ""));
62
75
  }
63
76
 
64
- function buildBm25Index(entries) {
65
- const documents = [];
77
+ function buildBm25Stats(units) {
66
78
  const documentFrequency = new Map();
67
79
  let totalLength = 0;
68
-
69
- entries.forEach((entry, index) => {
70
- const haystack = buildLexicalHaystack(entry);
71
-
72
- const tokens = tokenize(haystack);
73
- const termFreq = new Map();
74
- tokens.forEach((token) => {
75
- termFreq.set(token, (termFreq.get(token) || 0) + 1);
76
- });
77
-
78
- const seen = new Set();
79
- for (const token of tokens) {
80
- if (seen.has(token)) continue;
81
- seen.add(token);
80
+ for (const unit of units) {
81
+ unit.termFreq = new Map();
82
+ for (const token of unit.tokens) {
83
+ unit.termFreq.set(token, (unit.termFreq.get(token) || 0) + 1);
84
+ }
85
+ for (const token of unit.termFreq.keys()) {
82
86
  documentFrequency.set(token, (documentFrequency.get(token) || 0) + 1);
83
87
  }
88
+ unit.length = unit.tokens.length;
89
+ totalLength += unit.length;
90
+ delete unit.tokens;
91
+ }
92
+ return {
93
+ documentFrequency,
94
+ totalDocuments: units.length,
95
+ avgLength: units.length > 0 ? totalLength / units.length : 0
96
+ };
97
+ }
84
98
 
85
- totalLength += tokens.length;
86
- documents.push({
87
- index,
88
- entry,
89
- length: tokens.length,
90
- termFreq,
91
- snippetText: entry.embedText || entry.summary || entry.title || ""
92
- });
93
- });
94
-
95
- const avgLength = documents.length > 0 ? totalLength / documents.length : 0;
99
+ // Two BM25 indexes: one header unit per entry, one unit per body section.
100
+ // A page scores header + best section.
101
+ function buildBm25Index(entries) {
102
+ const documents = entries.map((entry, index) => ({
103
+ index,
104
+ entry,
105
+ tokens: tokenize(buildHeaderText(entry)),
106
+ sections: [],
107
+ fallbackSnippet: entry.summary || entry.title || ""
108
+ }));
109
+ const sections = [];
110
+ for (const doc of documents) {
111
+ for (const text of buildBodySections(doc.entry)) {
112
+ const section = { text, tokens: tokenize(text) };
113
+ doc.sections.push(section);
114
+ sections.push(section);
115
+ }
116
+ }
96
117
  return {
97
118
  documents,
98
- documentFrequency,
99
- totalDocuments: documents.length,
100
- avgLength
119
+ headerStats: buildBm25Stats(documents),
120
+ sectionStats: buildBm25Stats(sections)
101
121
  };
102
122
  }
103
123
 
104
- function computeBm25Score(indexState, docState, terms) {
124
+ function computeBm25Score(stats, unit, terms) {
105
125
  if (terms.length === 0) return 0;
106
- if (!docState.length || !indexState.avgLength) return 0;
126
+ if (!unit.length || !stats.avgLength) return 0;
107
127
 
108
128
  let score = 0;
109
129
  for (const term of terms) {
110
- const tf = docState.termFreq.get(term) || 0;
130
+ const tf = unit.termFreq.get(term) || 0;
111
131
  if (!tf) continue;
112
- const df = indexState.documentFrequency.get(term) || 0;
113
- const idf = Math.log(1 + (indexState.totalDocuments - df + 0.5) / (df + 0.5));
114
- const denom = tf + BM25_K1 * (1 - BM25_B + BM25_B * (docState.length / indexState.avgLength));
132
+ const df = stats.documentFrequency.get(term) || 0;
133
+ const idf = Math.log(1 + (stats.totalDocuments - df + 0.5) / (df + 0.5));
134
+ const denom = tf + BM25_K1 * (1 - BM25_B + BM25_B * (unit.length / stats.avgLength));
115
135
  score += idf * ((tf * (BM25_K1 + 1)) / denom);
116
136
  }
117
137
 
118
138
  return score;
119
139
  }
120
140
 
141
+ function scoreDocument(index, doc, terms) {
142
+ let best = null;
143
+ let bestScore = 0;
144
+ for (const section of doc.sections) {
145
+ const score = computeBm25Score(index.sectionStats, section, terms);
146
+ if (score > bestScore) {
147
+ bestScore = score;
148
+ best = section;
149
+ }
150
+ }
151
+ return {
152
+ score: computeBm25Score(index.headerStats, doc, terms) + bestScore,
153
+ snippetText: best ? best.text : doc.fallbackSnippet
154
+ };
155
+ }
156
+
121
157
  function createLexicalProvider({
122
158
  entries,
123
159
  entryMatchesScope,
@@ -126,10 +162,24 @@ function createLexicalProvider({
126
162
  bm25Weight = 0.7,
127
163
  fuseWeight = 0.3
128
164
  }) {
129
- const fuse = new Fuse(entries, fuseOptions);
130
165
  const bm25Index = buildBm25Index(entries);
131
166
  const entryByUri = new Map(entries.map((entry) => [entry.uri, entry]));
132
- const snippetTextByUri = new Map(bm25Index.documents.map((doc) => [doc.entry.uri, doc.snippetText]));
167
+ // Fuse cost grows with the number of entries scanned, and its hits were
168
+ // filtered by scope only afterwards. One Fuse index per scope keeps scoped
169
+ // searches proportional to that scope; the scope set is small and bounded.
170
+ const fuseByScope = new Map();
171
+ const getScopedFuse = (filters) => {
172
+ const key = [filters?.product, filters?.edition, filters?.platform, filters?.type].map((v) => v || "").join("|");
173
+ let scoped = fuseByScope.get(key);
174
+ if (!scoped) {
175
+ scoped = new Fuse(entries.filter((entry) => entryMatchesScope(entry, filters || {})), fuseOptions);
176
+ if (fuseByScope.size >= MAX_SCOPED_FUSE_INDEXES) {
177
+ fuseByScope.delete(fuseByScope.keys().next().value);
178
+ }
179
+ fuseByScope.set(key, scoped);
180
+ }
181
+ return scoped;
182
+ };
133
183
 
134
184
  return {
135
185
  name: "lexical",
@@ -137,7 +187,7 @@ function createLexicalProvider({
137
187
  // Expand with domain synonyms/stems so vocabulary gaps (webcam/camera,
138
188
  // datamatrix/data matrix, scanning/scan) do not zero the BM25 component.
139
189
  const terms = expandQueryTokens([...new Set(tokenize(query))]);
140
- const fuseHits = fuse.search(query);
190
+ const fuseHits = query.length <= MAX_FUSE_QUERY_CHARS ? getScopedFuse(filters).search(query) : [];
141
191
  const fuseScoreByUri = new Map();
142
192
 
143
193
  for (const hit of fuseHits) {
@@ -154,9 +204,11 @@ function createLexicalProvider({
154
204
  // out-of-scope fuzzy match deflate every in-scope fuse contribution (issue #147).
155
205
  let maxFuse = 0;
156
206
 
207
+ const snippetTextByUri = new Map();
157
208
  for (const doc of bm25Index.documents) {
158
209
  if (!entryMatchesScope(doc.entry, filters)) continue;
159
- const score = computeBm25Score(bm25Index, doc, terms);
210
+ const { score, snippetText } = scoreDocument(bm25Index, doc, terms);
211
+ snippetTextByUri.set(doc.entry.uri, snippetText);
160
212
  if (score > 0) {
161
213
  bm25ScoreByUri.set(doc.entry.uri, score);
162
214
  if (score > maxBm25) maxBm25 = score;
@@ -78,9 +78,11 @@ function chunkMarkdown(text, chunkSize, chunkOverlap, maxChunks) {
78
78
  const sections = [];
79
79
  let current = { path: "", lines: [] };
80
80
 
81
+ // Heading-only sections (e.g. index pages whose headings are links) are
82
+ // kept: their heading path is the only text that makes them findable.
81
83
  const flush = () => {
82
84
  const body = current.lines.join(" ").replace(/\s+/g, " ").trim();
83
- if (body) sections.push({ path: current.path, body });
85
+ if (body || current.path) sections.push({ path: current.path, body });
84
86
  };
85
87
 
86
88
  let inFence = false;
@@ -116,6 +118,11 @@ function chunkMarkdown(text, chunkSize, chunkOverlap, maxChunks) {
116
118
  const chunks = [];
117
119
  for (const section of sections) {
118
120
  const prefix = section.path ? `${section.path}\n` : "";
121
+ if (!section.body) {
122
+ chunks.push(section.path);
123
+ if (maxChunks && chunks.length >= maxChunks) return chunks;
124
+ continue;
125
+ }
119
126
  const pieces = chunkText(section.body, chunkSize, chunkOverlap, maxChunks);
120
127
  for (const piece of pieces) {
121
128
  chunks.push(`${prefix}${piece}`.trim());
@@ -248,6 +255,7 @@ function buildIndexSignature({ pkgVersion, signatureData, ragConfig }) {
248
255
  dbrWebDocCount: signatureData.dbrWebDocCount,
249
256
  dbrMobileDocCount: signatureData.dbrMobileDocCount,
250
257
  dbrServerDocCount: signatureData.dbrServerDocCount,
258
+ dbrSharedDocCount: signatureData.dbrSharedDocCount,
251
259
  dwtDocCount: signatureData.dwtDocCount,
252
260
  ddvDocCount: signatureData.ddvDocCount,
253
261
  versions: signatureData.versions,
@@ -257,7 +265,9 @@ function buildIndexSignature({ pkgVersion, signatureData, ragConfig }) {
257
265
  maxChunksPerDoc: ragConfig.maxChunksPerDoc,
258
266
  maxTextChars: ragConfig.maxTextChars,
259
267
  // Bump when the chunking strategy changes so cached vector indexes rebuild.
260
- chunkStrategy: "heading-aware-v1"
268
+ chunkStrategy: "heading-aware-v2",
269
+ // Bump when doc:// URIs change shape; cached vectors are keyed by URI.
270
+ docUriScheme: "source-path-v1"
261
271
  });
262
272
  }
263
273
 
@@ -20,6 +20,70 @@ function makeCheckpointFileName(provider, model, cacheKey) {
20
20
  return `rag-${provider}-${safeModel}-${cacheKey.slice(0, 12)}.checkpoint.json`;
21
21
  }
22
22
 
23
+ // Cache layout: a JSON header (`<name>.json`: cacheKey, meta, items, vector
24
+ // count/dimensions) plus a sibling `<name>.f32` file holding all vectors as
25
+ // packed little-endian float32. Serializing vectors into JSON overflowed V8's
26
+ // ~512MB max string length once the index grew; neither file here is built as
27
+ // one large string.
28
+ const VECTOR_FORMAT = "float32-le-v1";
29
+
30
+ function vectorFileFor(headerFile) {
31
+ return headerFile.replace(/\.json$/i, ".f32");
32
+ }
33
+
34
+ function writeVectorFiles(headerFile, payload) {
35
+ const { vectors, ...header } = payload;
36
+ const list = Array.isArray(vectors) ? vectors : [];
37
+ const dimensions = list.length > 0 ? list[0].length : 0;
38
+ const packed = new Float32Array(list.length * dimensions);
39
+ list.forEach((vector, index) => {
40
+ if (vector.length !== dimensions) {
41
+ throw new Error(`Vector ${index} has ${vector.length} dimensions; expected ${dimensions}`);
42
+ }
43
+ packed.set(vector, index * dimensions);
44
+ });
45
+ // Vectors first: a header always describes a complete vector file, and a
46
+ // stale header next to a newer vector file fails the size check on load.
47
+ writeFileSync(vectorFileFor(headerFile), Buffer.from(packed.buffer, packed.byteOffset, packed.byteLength));
48
+ writeFileSync(headerFile, JSON.stringify({
49
+ ...header,
50
+ vectorFormat: VECTOR_FORMAT,
51
+ vectorCount: list.length,
52
+ dimensions
53
+ }));
54
+ }
55
+
56
+ // Returns the parsed header with `vectors` as Float32Array views over one
57
+ // buffer, or { error } describing why the files cannot be used.
58
+ function readVectorFiles(headerFile) {
59
+ const header = JSON.parse(readFileSync(headerFile, "utf8"));
60
+ if (!header || header.vectorFormat !== VECTOR_FORMAT || !Array.isArray(header.items)) {
61
+ return { error: "invalid_payload" };
62
+ }
63
+ const vectorFile = vectorFileFor(headerFile);
64
+ if (!existsSync(vectorFile)) {
65
+ return { error: "vectors_missing" };
66
+ }
67
+ const count = Number(header.vectorCount) || 0;
68
+ const dimensions = Number(header.dimensions) || 0;
69
+ const raw = readFileSync(vectorFile);
70
+ if (raw.byteLength !== count * dimensions * Float32Array.BYTES_PER_ELEMENT) {
71
+ return { error: "vectors_size_mismatch" };
72
+ }
73
+ // Copy into an aligned buffer; Buffer offsets are not guaranteed 4-byte aligned.
74
+ const all = new Float32Array(raw.buffer.slice(raw.byteOffset, raw.byteOffset + raw.byteLength));
75
+ const vectors = new Array(count);
76
+ for (let i = 0; i < count; i += 1) {
77
+ vectors[i] = all.subarray(i * dimensions, (i + 1) * dimensions);
78
+ }
79
+ return { payload: { ...header, vectors } };
80
+ }
81
+
82
+ function removeVectorFiles(headerFile) {
83
+ rmSync(headerFile, { force: true });
84
+ rmSync(vectorFileFor(headerFile), { force: true });
85
+ }
86
+
23
87
  function loadVectorIndexCache(
24
88
  cacheFile,
25
89
  { cacheKey, signature, provider, model, requireSignature = false } = {}
@@ -28,12 +92,12 @@ function loadVectorIndexCache(
28
92
  return { hit: false, reason: "missing", payload: null };
29
93
  }
30
94
  try {
31
- const parsed = JSON.parse(readFileSync(cacheFile, "utf8"));
32
- if (!parsed || (cacheKey && parsed.cacheKey !== cacheKey)) {
33
- return { hit: false, reason: "cache_key_mismatch", payload: null };
95
+ const { payload: parsed, error } = readVectorFiles(cacheFile);
96
+ if (error) {
97
+ return { hit: false, reason: error, payload: null };
34
98
  }
35
- if (!Array.isArray(parsed.items) || !Array.isArray(parsed.vectors)) {
36
- return { hit: false, reason: "invalid_payload", payload: null };
99
+ if (cacheKey && parsed.cacheKey !== cacheKey) {
100
+ return { hit: false, reason: "cache_key_mismatch", payload: null };
37
101
  }
38
102
  const meta = parsed.meta || {};
39
103
  if (provider && meta.provider && meta.provider !== provider) {
@@ -59,23 +123,7 @@ function loadVectorIndexCache(
59
123
 
60
124
  function saveVectorIndexCache(cacheDir, cacheFile, payload) {
61
125
  ensureDirectory(cacheDir);
62
- let serialized;
63
- try {
64
- serialized = JSON.stringify(payload);
65
- } catch (error) {
66
- // JSON.stringify throws "Invalid string length" once the serialized cache
67
- // exceeds V8's ~512MB max string length (reached with a large index of
68
- // high-dimensional vectors). Fail with an actionable message instead.
69
- if (error instanceof RangeError) {
70
- const itemCount = Array.isArray(payload?.items) ? payload.items.length : "unknown";
71
- throw new Error(
72
- `Vector cache too large to serialize (${itemCount} items) — exceeds the JSON string limit. ` +
73
- "Lower RAG_MAX_CHUNKS_PER_DOC or adopt a ceiling-proof cache format."
74
- );
75
- }
76
- throw error;
77
- }
78
- writeFileSync(cacheFile, serialized);
126
+ writeVectorFiles(cacheFile, payload);
79
127
  }
80
128
 
81
129
  function loadVectorIndexCheckpoint(checkpointFile, expectedKey, expectedItems) {
@@ -83,12 +131,12 @@ function loadVectorIndexCheckpoint(checkpointFile, expectedKey, expectedItems) {
83
131
  return { hit: false, reason: "missing", payload: null };
84
132
  }
85
133
  try {
86
- const parsed = JSON.parse(readFileSync(checkpointFile, "utf8"));
87
- if (!parsed || parsed.cacheKey !== expectedKey) {
88
- return { hit: false, reason: "cache_key_mismatch", payload: null };
134
+ const { payload: parsed, error } = readVectorFiles(checkpointFile);
135
+ if (error) {
136
+ return { hit: false, reason: error, payload: null };
89
137
  }
90
- if (!Array.isArray(parsed.items) || !Array.isArray(parsed.vectors)) {
91
- return { hit: false, reason: "invalid_payload", payload: null };
138
+ if (parsed.cacheKey !== expectedKey) {
139
+ return { hit: false, reason: "cache_key_mismatch", payload: null };
92
140
  }
93
141
  if (parsed.items.length !== expectedItems.length) {
94
142
  return { hit: false, reason: "items_length_mismatch", payload: null };
@@ -109,13 +157,11 @@ function loadVectorIndexCheckpoint(checkpointFile, expectedKey, expectedItems) {
109
157
 
110
158
  function saveVectorIndexCheckpoint(cacheDir, checkpointFile, payload) {
111
159
  ensureDirectory(cacheDir);
112
- writeFileSync(checkpointFile, JSON.stringify(payload));
160
+ writeVectorFiles(checkpointFile, payload);
113
161
  }
114
162
 
115
163
  function clearVectorIndexCheckpoint(checkpointFile) {
116
- if (existsSync(checkpointFile)) {
117
- rmSync(checkpointFile, { force: true });
118
- }
164
+ removeVectorFiles(checkpointFile);
119
165
  }
120
166
 
121
167
  function listFilesRecursive(rootDir) {
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readdirSync, statSync } from "node:fs";
2
2
  import { extname, join } from "node:path";
3
3
  import { DDV_PREFERRED_ENTRY_FILES } from "./config.js";
4
+ import { shortDocPath } from "./docs-loader.js";
4
5
  import { PRODUCT_SELECTION_GUIDANCE, PUBLIC_OFFERING_PRODUCTS } from "../public-offerings.js";
5
6
 
6
7
  const MRZ_MATCHER = /(?:\bmrz\b|machine[-\s]?readable[-\s]?zone|passport)/i;
@@ -201,6 +202,29 @@ function addPublicResourceToIndex(addResourceToIndex, entry) {
201
202
  }
202
203
  }
203
204
 
205
+ function encodeDocSlug(slug) {
206
+ return String(slug).split("/").map((segment) => encodeURIComponent(segment)).join("/");
207
+ }
208
+
209
+ // One doc:// slug per article: its short path, or (on a collision within the
210
+ // same URI prefix + platform, e.g. `user-guide.md` vs `user-guide/index.md`)
211
+ // its full source path. Shared pages keep their `slugPrefix` namespace
212
+ // (`dcv`, `dcv-core`) in both forms.
213
+ function assignDocSlugs(docs, defaultPlatform) {
214
+ const withPrefix = (article, slug) => (article.slugPrefix ? `${article.slugPrefix}/${slug}` : slug);
215
+ const keyFor = (article, slug) => `${article.platform || defaultPlatform}/${slug}`;
216
+ const shortSlugs = docs.map((article) => withPrefix(article, shortDocPath(article.path || article.title)));
217
+ const counts = new Map();
218
+ docs.forEach((article, i) => {
219
+ const key = keyFor(article, shortSlugs[i]);
220
+ counts.set(key, (counts.get(key) || 0) + 1);
221
+ });
222
+ return docs.map((article, i) => {
223
+ if (counts.get(keyFor(article, shortSlugs[i])) === 1) return shortSlugs[i];
224
+ return withPrefix(article, String(article.path || article.title).replace(/\\/g, "/").replace(/\.md$/i, ""));
225
+ });
226
+ }
227
+
204
228
  function addMarkdownDocResources({
205
229
  addResourceToIndex,
206
230
  docs,
@@ -214,12 +238,18 @@ function addMarkdownDocResources({
214
238
  defaultSummary,
215
239
  baseTags
216
240
  }) {
241
+ const slugs = assignDocSlugs(docs, defaultPlatform);
217
242
  for (let i = 0; i < docs.length; i++) {
218
243
  const article = docs[i];
219
244
  if (!article?.title) continue;
220
- const slug = `${encodeURIComponent(article.title)}-${i}`;
245
+ const slug = encodeDocSlug(slugs[i]);
221
246
  const platform = article.platform || defaultPlatform;
222
247
  const tags = [...baseTags, platform];
248
+ if (Array.isArray(article.platforms)) {
249
+ for (const extra of article.platforms) {
250
+ if (!tags.includes(extra)) tags.push(extra);
251
+ }
252
+ }
223
253
  if (article.breadcrumb) tags.push(...article.breadcrumb.toLowerCase().split(/\s*>\s*/));
224
254
  // Fold frontmatter keywords into tags so domain-vocabulary search hits (#142).
225
255
  if (Array.isArray(article.keywords)) {
@@ -239,7 +269,9 @@ function addMarkdownDocResources({
239
269
  product,
240
270
  edition,
241
271
  platform,
272
+ ...(Array.isArray(article.platforms) ? { platforms: article.platforms } : {}),
242
273
  version,
274
+ ...(article.sourceVersion ? { sourceVersion: article.sourceVersion } : {}),
243
275
  majorVersion,
244
276
  url: article.url,
245
277
  title: product === "mrz" || product === "mds"
@@ -601,6 +633,7 @@ function buildResourceIndex({
601
633
  dbrWebDocs,
602
634
  dbrMobileDocs,
603
635
  dbrServerDocs,
636
+ dbrSharedDocs,
604
637
  dwtDocs,
605
638
  ddvDocs,
606
639
  discoverDcvMobileSamples,
@@ -967,6 +1000,27 @@ function buildResourceIndex({
967
1000
  baseTags: ["doc", "dbr", "server"]
968
1001
  });
969
1002
 
1003
+ const sharedEditions = [
1004
+ ["web", dbrWebVersion, "Web"],
1005
+ ["mobile", dbrMobileVersion, "Mobile"],
1006
+ ["server", dbrServerVersion, "Server/Desktop"]
1007
+ ];
1008
+ for (const [edition, version, label] of sharedEditions) {
1009
+ addMarkdownDocResources({
1010
+ addResourceToIndex,
1011
+ docs: dbrSharedDocs?.[edition] || [],
1012
+ idPrefix: `dbr-${edition}-shared-doc`,
1013
+ uriPrefix: `doc://dbr/${edition}`,
1014
+ product: "dbr",
1015
+ edition,
1016
+ version,
1017
+ majorVersion: LATEST_MAJOR.dbr,
1018
+ defaultPlatform: edition,
1019
+ defaultSummary: `Shared Capture Vision API used by Dynamsoft Barcode Reader ${label}`,
1020
+ baseTags: ["doc", "dbr", edition, "shared", "capture-vision"]
1021
+ });
1022
+ }
1023
+
970
1024
  for (const platform of getDbrMobilePlatforms()) {
971
1025
  const samples = discoverMobileSamples(platform);
972
1026
  for (const level of ["high-level", "low-level"]) {
@@ -1155,6 +1209,7 @@ function buildResourceIndex({
1155
1209
  }
1156
1210
 
1157
1211
  export {
1212
+ assignDocSlugs,
1158
1213
  buildIndexData,
1159
1214
  buildResourceIndex
1160
1215
  };
@@ -98,9 +98,56 @@ function markdownPathToUrl(baseUrl, relativePath) {
98
98
  return `${baseUrl}${normalized}`;
99
99
  }
100
100
 
101
- function loadMarkdownDocs({ rootDir, urlBase, includeDirNames = [], excludeDirs = [], excludeFiles = [] }) {
101
+ // Path used in doc:// URIs. The platform is already a URI segment, so the
102
+ // `programming/<lang>/` prefix is dropped, and a directory's index page is
103
+ // addressed by the directory.
104
+ function shortDocPath(relativePath) {
105
+ const withoutExt = String(relativePath || "").replace(/\\/g, "/").replace(/\.md$/i, "");
106
+ const withoutPlatform = withoutExt.replace(/^programming\/[^/]+\//, "");
107
+ const withoutIndex = withoutPlatform.replace(/(^|\/)index$/, "");
108
+ return withoutIndex || "index";
109
+ }
110
+
111
+ const SITE_ORIGIN = "https://www.dynamsoft.com";
112
+
113
+ // Top-level `key: value` pairs from a Jekyll _config.yml. Only path/URL values
114
+ // are kept: they are what docs interpolate as `{{ site.key }}` link prefixes.
115
+ function loadSiteVariables(siteRoot) {
116
+ const vars = new Map();
117
+ const configPath = join(siteRoot || "", "_config.yml");
118
+ if (!siteRoot || !existsSync(configPath)) return vars;
119
+ for (const line of readFileSync(configPath, "utf8").split(/\r?\n/)) {
120
+ const match = line.match(/^([A-Za-z0-9_-]+):\s*["']?([^"'#\s]+)["']?\s*(?:#.*)?$/);
121
+ if (!match) continue;
122
+ const [, key, value] = match;
123
+ if (value.startsWith("/")) {
124
+ vars.set(key, `${SITE_ORIGIN}${value}`);
125
+ } else if (/^https?:\/\//i.test(value)) {
126
+ vars.set(key, value);
127
+ }
128
+ }
129
+ return vars;
130
+ }
131
+
132
+ // Replace `{{ site.key }}` with the absolute URL from _config.yml so links in
133
+ // returned docs (and the shared-doc link map) point at the real public pages.
134
+ // Unknown keys are left untouched.
135
+ function resolveSiteLinks(text, siteVars) {
136
+ if (!text || !siteVars || siteVars.size === 0) return text;
137
+ return String(text).replace(/\{\{\s*site\.([A-Za-z0-9_-]+)\s*\}\}/g, (whole, key) => siteVars.get(key) ?? whole);
138
+ }
139
+
140
+ function loadMarkdownDocs({
141
+ rootDir,
142
+ urlBase,
143
+ siteRoot = rootDir,
144
+ includeDirNames = [],
145
+ excludeDirs = [],
146
+ excludeFiles = []
147
+ }) {
102
148
  if (!existsSync(rootDir)) return { articles: [] };
103
149
 
150
+ const siteVars = loadSiteVariables(siteRoot);
104
151
  const files = collectMarkdownFiles(rootDir, { includeDirNames, excludeDirs, excludeFiles });
105
152
  const articles = [];
106
153
 
@@ -108,6 +155,9 @@ function loadMarkdownDocs({ rootDir, urlBase, includeDirNames = [], excludeDirs
108
155
  const relativePath = relative(rootDir, filePath).replace(/\\/g, "/");
109
156
  const raw = readFileSync(filePath, "utf8");
110
157
  const parsed = parseFrontMatter(raw);
158
+ // Jekyll redirect stubs have no content of their own; indexing them yields
159
+ // empty hits whose URL only redirects elsewhere.
160
+ if (parsed.meta.redirect_to) continue;
111
161
  const title = parsed.meta.title || getHeadingTitle(parsed.body) || formatSegmentLabel(relativePath.replace(/\.md$/i, "").split("/").pop());
112
162
  if (!title) continue;
113
163
  const breadcrumb = parsed.meta.breadcrumbText || buildBreadcrumbFromPath(relativePath) || title;
@@ -121,7 +171,7 @@ function loadMarkdownDocs({ rootDir, urlBase, includeDirNames = [], excludeDirs
121
171
  articles.push({
122
172
  title,
123
173
  url: markdownPathToUrl(urlBase, relativePath),
124
- content: parsed.body.trim(),
174
+ content: resolveSiteLinks(parsed.body.trim(), siteVars),
125
175
  breadcrumb,
126
176
  description,
127
177
  keywords,
@@ -129,9 +179,12 @@ function loadMarkdownDocs({ rootDir, urlBase, includeDirNames = [], excludeDirs
129
179
  });
130
180
  }
131
181
 
132
- return { articles };
182
+ return { articles, siteVars };
133
183
  }
134
184
 
135
185
  export {
136
- loadMarkdownDocs
186
+ loadMarkdownDocs,
187
+ loadSiteVariables,
188
+ resolveSiteLinks,
189
+ shortDocPath
137
190
  };