chub-dev 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.
- package/bin/chub +2 -0
- package/package.json +40 -0
- package/src/commands/build.js +321 -0
- package/src/commands/cache.js +52 -0
- package/src/commands/get.js +157 -0
- package/src/commands/search.js +105 -0
- package/src/commands/update.js +56 -0
- package/src/index.js +109 -0
- package/src/lib/cache.js +287 -0
- package/src/lib/config.js +52 -0
- package/src/lib/frontmatter.js +14 -0
- package/src/lib/normalize.js +25 -0
- package/src/lib/output.js +33 -0
- package/src/lib/registry.js +284 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { loadSourceRegistry } from './cache.js';
|
|
2
|
+
import { loadConfig } from './config.js';
|
|
3
|
+
import { normalizeLanguage } from './normalize.js';
|
|
4
|
+
|
|
5
|
+
let _merged = null;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Load and merge entries from all configured sources.
|
|
9
|
+
* Returns { docs: [...], skills: [...] } with each entry tagged with _source/_sourceObj.
|
|
10
|
+
*/
|
|
11
|
+
function getMerged() {
|
|
12
|
+
if (_merged) return _merged;
|
|
13
|
+
|
|
14
|
+
const config = loadConfig();
|
|
15
|
+
const allDocs = [];
|
|
16
|
+
const allSkills = [];
|
|
17
|
+
|
|
18
|
+
for (const source of config.sources) {
|
|
19
|
+
const registry = loadSourceRegistry(source);
|
|
20
|
+
if (!registry) continue;
|
|
21
|
+
|
|
22
|
+
// Support both new format (docs/skills) and old format (entries)
|
|
23
|
+
if (registry.docs) {
|
|
24
|
+
for (const doc of registry.docs) {
|
|
25
|
+
allDocs.push({ ...doc, id: doc.id || doc.name, _source: source.name, _sourceObj: source });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (registry.skills) {
|
|
29
|
+
for (const skill of registry.skills) {
|
|
30
|
+
allSkills.push({ ...skill, id: skill.id || skill.name, _source: source.name, _sourceObj: source });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Backward compat: old entries[] format
|
|
35
|
+
if (registry.entries) {
|
|
36
|
+
for (const entry of registry.entries) {
|
|
37
|
+
const tagged = { ...entry, _source: source.name, _sourceObj: source };
|
|
38
|
+
const provides = entry.languages?.[0]?.versions?.[0]?.provides || [];
|
|
39
|
+
if (provides.includes('skill')) {
|
|
40
|
+
allSkills.push(tagged);
|
|
41
|
+
}
|
|
42
|
+
if (provides.includes('doc') || provides.length === 0) {
|
|
43
|
+
allDocs.push(tagged);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
_merged = { docs: allDocs, skills: allSkills };
|
|
50
|
+
return _merged;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Get all entries (docs + skills combined) for listing/searching.
|
|
55
|
+
*/
|
|
56
|
+
function getAllEntries() {
|
|
57
|
+
const { docs, skills } = getMerged();
|
|
58
|
+
// Tag each with _type for display
|
|
59
|
+
const taggedDocs = docs.map((d) => ({ ...d, _type: 'doc' }));
|
|
60
|
+
const taggedSkills = skills.map((s) => ({ ...s, _type: 'skill' }));
|
|
61
|
+
// Deduplicate: if same id+source appears in both, keep both but mark as bundled
|
|
62
|
+
return [...taggedDocs, ...taggedSkills];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Filter entries by the global source trust policy.
|
|
67
|
+
*/
|
|
68
|
+
function applySourceFilter(entries) {
|
|
69
|
+
const config = loadConfig();
|
|
70
|
+
const allowed = config.source.split(',').map((s) => s.trim().toLowerCase());
|
|
71
|
+
return entries.filter((e) => !e.source || allowed.includes(e.source.toLowerCase()));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Apply tag and language filters.
|
|
76
|
+
*/
|
|
77
|
+
function applyFilters(entries, filters) {
|
|
78
|
+
let result = entries;
|
|
79
|
+
|
|
80
|
+
if (filters.tags) {
|
|
81
|
+
const filterTags = filters.tags.split(',').map((t) => t.trim().toLowerCase());
|
|
82
|
+
result = result.filter((e) =>
|
|
83
|
+
filterTags.every((ft) => e.tags?.some((t) => t.toLowerCase() === ft))
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
if (filters.lang) {
|
|
87
|
+
const lang = normalizeLanguage(filters.lang);
|
|
88
|
+
result = result.filter((e) =>
|
|
89
|
+
e.languages?.some((l) => l.language === lang)
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Check if an id has collisions across sources.
|
|
98
|
+
*/
|
|
99
|
+
function getEntriesById(id, entries) {
|
|
100
|
+
return entries.filter((e) => e.id === id);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Check if we're in multi-source mode.
|
|
105
|
+
*/
|
|
106
|
+
export function isMultiSource() {
|
|
107
|
+
const config = loadConfig();
|
|
108
|
+
return config.sources.length > 1;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Get the display id for an entry — namespaced only on collision.
|
|
113
|
+
*/
|
|
114
|
+
export function getDisplayId(entry) {
|
|
115
|
+
if (!isMultiSource()) return entry.id;
|
|
116
|
+
const all = applySourceFilter(getAllEntries());
|
|
117
|
+
const matches = getEntriesById(entry.id, all).filter((e) => e._type === entry._type);
|
|
118
|
+
if (matches.length > 1) return `${entry._source}:${entry.id}`;
|
|
119
|
+
return entry.id;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Search entries by query string. Searches both docs and skills.
|
|
124
|
+
*/
|
|
125
|
+
export function searchEntries(query, filters = {}) {
|
|
126
|
+
const entries = applySourceFilter(getAllEntries());
|
|
127
|
+
const q = query.toLowerCase();
|
|
128
|
+
const words = q.split(/\s+/);
|
|
129
|
+
|
|
130
|
+
// Deduplicate: same id+source appearing as both doc and skill → show once
|
|
131
|
+
const seen = new Set();
|
|
132
|
+
const deduped = [];
|
|
133
|
+
for (const entry of entries) {
|
|
134
|
+
const key = `${entry._source}:${entry.id}`;
|
|
135
|
+
if (!seen.has(key)) {
|
|
136
|
+
seen.add(key);
|
|
137
|
+
deduped.push(entry);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let results = deduped.map((entry) => {
|
|
142
|
+
let score = 0;
|
|
143
|
+
|
|
144
|
+
if (entry.id === q) score += 100;
|
|
145
|
+
else if (entry.id.includes(q)) score += 50;
|
|
146
|
+
|
|
147
|
+
const nameLower = entry.name.toLowerCase();
|
|
148
|
+
if (nameLower === q) score += 80;
|
|
149
|
+
else if (nameLower.includes(q)) score += 40;
|
|
150
|
+
|
|
151
|
+
for (const word of words) {
|
|
152
|
+
if (entry.id.includes(word)) score += 10;
|
|
153
|
+
if (nameLower.includes(word)) score += 10;
|
|
154
|
+
if (entry.description?.toLowerCase().includes(word)) score += 5;
|
|
155
|
+
if (entry.tags?.some((t) => t.toLowerCase().includes(word))) score += 15;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return { entry, score };
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
results = results.filter((r) => r.score > 0);
|
|
162
|
+
|
|
163
|
+
const filtered = applyFilters(results.map((r) => r.entry), filters);
|
|
164
|
+
const filteredSet = new Set(filtered);
|
|
165
|
+
results = results.filter((r) => filteredSet.has(r.entry));
|
|
166
|
+
|
|
167
|
+
results.sort((a, b) => b.score - a.score);
|
|
168
|
+
return results.map((r) => ({ ...r.entry, _score: r.score }));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Get entry by id or source/id, from a specific type array.
|
|
173
|
+
* type: "doc" or "skill". If null, searches both.
|
|
174
|
+
*/
|
|
175
|
+
export function getEntry(idOrNamespacedId, type = null) {
|
|
176
|
+
const { docs, skills } = getMerged();
|
|
177
|
+
let pool;
|
|
178
|
+
if (type === 'doc') pool = applySourceFilter(docs);
|
|
179
|
+
else if (type === 'skill') pool = applySourceFilter(skills);
|
|
180
|
+
else pool = applySourceFilter([...docs, ...skills]);
|
|
181
|
+
|
|
182
|
+
// Check for source:id format (colon separates source from id)
|
|
183
|
+
if (idOrNamespacedId.includes(':')) {
|
|
184
|
+
const colonIdx = idOrNamespacedId.indexOf(':');
|
|
185
|
+
const sourceName = idOrNamespacedId.slice(0, colonIdx);
|
|
186
|
+
const id = idOrNamespacedId.slice(colonIdx + 1);
|
|
187
|
+
const entry = pool.find((e) => e._source === sourceName && e.id === id);
|
|
188
|
+
return entry ? { entry, ambiguous: false } : { entry: null, ambiguous: false };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Bare id (may contain slashes like author/name)
|
|
192
|
+
const matches = pool.filter((e) => e.id === idOrNamespacedId);
|
|
193
|
+
if (matches.length === 0) return { entry: null, ambiguous: false };
|
|
194
|
+
if (matches.length === 1) return { entry: matches[0], ambiguous: false };
|
|
195
|
+
|
|
196
|
+
// Ambiguous — multiple sources have this id
|
|
197
|
+
return {
|
|
198
|
+
entry: null,
|
|
199
|
+
ambiguous: true,
|
|
200
|
+
alternatives: matches.map((e) => `${e._source}:${e.id}`),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* List entries with optional filters. Searches both docs and skills, deduped.
|
|
206
|
+
*/
|
|
207
|
+
export function listEntries(filters = {}) {
|
|
208
|
+
const entries = applySourceFilter(getAllEntries());
|
|
209
|
+
// Deduplicate
|
|
210
|
+
const seen = new Set();
|
|
211
|
+
const deduped = [];
|
|
212
|
+
for (const entry of entries) {
|
|
213
|
+
const key = `${entry._source}:${entry.id}`;
|
|
214
|
+
if (!seen.has(key)) {
|
|
215
|
+
seen.add(key);
|
|
216
|
+
deduped.push(entry);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return applyFilters(deduped, filters);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Resolve the doc path + source for a doc entry.
|
|
224
|
+
* Returns { source, path, files } or null.
|
|
225
|
+
* If language is null and multiple languages exist, returns { needsLanguage: true, available: [...] }.
|
|
226
|
+
*/
|
|
227
|
+
export function resolveDocPath(entry, language, version) {
|
|
228
|
+
const lang = language ? normalizeLanguage(language) : null;
|
|
229
|
+
|
|
230
|
+
// Skills are flat — no language/version nesting
|
|
231
|
+
if (!entry.languages) {
|
|
232
|
+
// This is a skill entry — path is directly on the entry
|
|
233
|
+
if (!entry.path) return null;
|
|
234
|
+
return {
|
|
235
|
+
source: entry._sourceObj,
|
|
236
|
+
path: entry.path,
|
|
237
|
+
files: entry.files || [],
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
let langObj = null;
|
|
242
|
+
if (lang) {
|
|
243
|
+
langObj = entry.languages.find((l) => l.language === lang);
|
|
244
|
+
} else if (entry.languages.length === 1) {
|
|
245
|
+
langObj = entry.languages[0];
|
|
246
|
+
} else if (entry.languages.length > 1) {
|
|
247
|
+
return {
|
|
248
|
+
needsLanguage: true,
|
|
249
|
+
available: entry.languages.map((l) => l.language),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (!langObj) return null;
|
|
254
|
+
|
|
255
|
+
let verObj = null;
|
|
256
|
+
if (version) {
|
|
257
|
+
verObj = langObj.versions?.find((v) => v.version === version);
|
|
258
|
+
} else {
|
|
259
|
+
const rec = langObj.recommendedVersion;
|
|
260
|
+
verObj = langObj.versions?.find((v) => v.version === rec) || langObj.versions?.[0];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (!verObj?.path) return null;
|
|
264
|
+
return {
|
|
265
|
+
source: entry._sourceObj,
|
|
266
|
+
path: verObj.path,
|
|
267
|
+
files: verObj.files || [],
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Given a resolved path and a type ("doc" or "skill"), return the entry file path.
|
|
273
|
+
*/
|
|
274
|
+
export function resolveEntryFile(resolved, type) {
|
|
275
|
+
if (!resolved || resolved.needsLanguage) return { error: 'unresolved' };
|
|
276
|
+
|
|
277
|
+
const fileName = type === 'skill' ? 'SKILL.md' : 'DOC.md';
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
filePath: `${resolved.path}/${fileName}`,
|
|
281
|
+
basePath: resolved.path,
|
|
282
|
+
files: resolved.files,
|
|
283
|
+
};
|
|
284
|
+
}
|