lat.md 0.4.2 → 0.5.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/dist/src/cli/check.js +19 -7
- package/dist/src/cli/context.js +1 -0
- package/dist/src/lattice.js +96 -37
- package/dist/src/walk.d.ts +0 -4
- package/dist/src/walk.js +1 -17
- package/package.json +1 -1
package/dist/src/cli/check.js
CHANGED
|
@@ -127,19 +127,26 @@ function immediateEntries(walkedPaths) {
|
|
|
127
127
|
}
|
|
128
128
|
return [...entries].sort();
|
|
129
129
|
}
|
|
130
|
-
/** Parse bullet items from an index file. Matches `-
|
|
130
|
+
/** Parse bullet items from an index file. Matches `- [[name]] — description` */
|
|
131
131
|
function parseIndexEntries(content) {
|
|
132
132
|
const names = new Set();
|
|
133
|
-
const re = /^-
|
|
133
|
+
const re = /^- \[\[([^\]]+?)(?:\|[^\]]+)?\]\]/gm;
|
|
134
134
|
let match;
|
|
135
135
|
while ((match = re.exec(content)) !== null) {
|
|
136
136
|
names.add(match[1]);
|
|
137
137
|
}
|
|
138
138
|
return names;
|
|
139
139
|
}
|
|
140
|
+
/**
|
|
141
|
+
* Convert a filesystem entry name to its wiki link stem.
|
|
142
|
+
* Strips `.md` extension from files; directories stay as-is.
|
|
143
|
+
*/
|
|
144
|
+
function entryToStem(name) {
|
|
145
|
+
return name.endsWith('.md') ? name.slice(0, -3) : name;
|
|
146
|
+
}
|
|
140
147
|
/** Generate a bullet-list snippet for the given entry names. */
|
|
141
148
|
function indexSnippet(entries) {
|
|
142
|
-
return entries.map((e) => `-
|
|
149
|
+
return entries.map((e) => `- [[${entryToStem(e)}]] — <describe>`).join('\n');
|
|
143
150
|
}
|
|
144
151
|
export async function checkIndex(latticeDir) {
|
|
145
152
|
const errors = [];
|
|
@@ -183,12 +190,16 @@ export async function checkIndex(latticeDir) {
|
|
|
183
190
|
});
|
|
184
191
|
continue;
|
|
185
192
|
}
|
|
186
|
-
// Parse existing entries and validate
|
|
193
|
+
// Parse existing entries and validate.
|
|
194
|
+
// Listed entries are wiki link stems (no .md extension).
|
|
195
|
+
// Children are filesystem names (with .md for files, bare for dirs).
|
|
187
196
|
const listed = parseIndexEntries(content);
|
|
197
|
+
const childStems = new Set(children.map(entryToStem));
|
|
198
|
+
const stemToChild = new Map(children.map((c) => [entryToStem(c), c]));
|
|
188
199
|
const relDir = dir === '' ? basename(latticeDir) + '/' : dir + '/';
|
|
189
200
|
const missing = [];
|
|
190
201
|
for (const child of children) {
|
|
191
|
-
if (!listed.has(child)) {
|
|
202
|
+
if (!listed.has(entryToStem(child))) {
|
|
192
203
|
missing.push(child);
|
|
193
204
|
}
|
|
194
205
|
}
|
|
@@ -199,11 +210,12 @@ export async function checkIndex(latticeDir) {
|
|
|
199
210
|
snippet: indexSnippet(missing),
|
|
200
211
|
});
|
|
201
212
|
}
|
|
213
|
+
const indexStem = entryToStem(indexFileName);
|
|
202
214
|
for (const name of listed) {
|
|
203
|
-
if (!
|
|
215
|
+
if (!childStems.has(name) && name !== indexStem) {
|
|
204
216
|
errors.push({
|
|
205
217
|
dir: relDir,
|
|
206
|
-
message: `"${indexRelPath}" lists "${name}" but it does not exist`,
|
|
218
|
+
message: `"${indexRelPath}" lists "[[${name}]]" but it does not exist`,
|
|
207
219
|
});
|
|
208
220
|
}
|
|
209
221
|
}
|
package/dist/src/cli/context.js
CHANGED
|
@@ -8,6 +8,7 @@ export function resolveContext(opts) {
|
|
|
8
8
|
const latDir = findLatticeDir(opts.dir) ?? '';
|
|
9
9
|
if (!latDir) {
|
|
10
10
|
console.error(chalk.red('No lat.md directory found'));
|
|
11
|
+
console.error(chalk.dim('Run `lat init` to create one.'));
|
|
11
12
|
process.exit(1);
|
|
12
13
|
}
|
|
13
14
|
return { latDir, color, chalk };
|
package/dist/src/lattice.js
CHANGED
|
@@ -81,7 +81,7 @@ export function parseSections(filePath, content, latticeDir) {
|
|
|
81
81
|
stack.pop();
|
|
82
82
|
}
|
|
83
83
|
const parent = stack.length > 0 ? stack[stack.length - 1] : null;
|
|
84
|
-
const id = parent ? `${parent.id}#${heading}` : file
|
|
84
|
+
const id = parent ? `${parent.id}#${heading}` : `${file}#${heading}`;
|
|
85
85
|
const section = {
|
|
86
86
|
id,
|
|
87
87
|
heading,
|
|
@@ -131,28 +131,14 @@ export function parseSections(filePath, content, latticeDir) {
|
|
|
131
131
|
}
|
|
132
132
|
return roots;
|
|
133
133
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
if (cached)
|
|
141
|
-
return cached;
|
|
134
|
+
export async function loadAllSections(latticeDir) {
|
|
135
|
+
const files = await listLatticeFiles(latticeDir);
|
|
136
|
+
const all = [];
|
|
137
|
+
for (const file of files) {
|
|
138
|
+
const content = await readFile(file, 'utf-8');
|
|
139
|
+
all.push(...parseSections(file, content, latticeDir));
|
|
142
140
|
}
|
|
143
|
-
|
|
144
|
-
const files = await listLatticeFiles(latticeDir);
|
|
145
|
-
const all = [];
|
|
146
|
-
for (const file of files) {
|
|
147
|
-
const content = await readFile(file, 'utf-8');
|
|
148
|
-
all.push(...parseSections(file, content, latticeDir));
|
|
149
|
-
}
|
|
150
|
-
return all;
|
|
151
|
-
})();
|
|
152
|
-
if (!noCache) {
|
|
153
|
-
sectionsCache.set(key, result);
|
|
154
|
-
}
|
|
155
|
-
return result;
|
|
141
|
+
return all;
|
|
156
142
|
}
|
|
157
143
|
export function flattenSections(sections) {
|
|
158
144
|
const result = [];
|
|
@@ -232,17 +218,39 @@ export function resolveRef(target, sectionIds, fileIndex) {
|
|
|
232
218
|
const hashIdx = target.indexOf('#');
|
|
233
219
|
const filePart = hashIdx === -1 ? target : target.slice(0, hashIdx);
|
|
234
220
|
const rest = hashIdx === -1 ? '' : target.slice(hashIdx);
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
221
|
+
// Try resolving the file part: either it's a full path or a bare stem
|
|
222
|
+
const filePaths = fileIndex.has(filePart)
|
|
223
|
+
? fileIndex.get(filePart)
|
|
224
|
+
: [filePart];
|
|
225
|
+
if (filePaths.length === 1) {
|
|
226
|
+
const fp = filePaths[0];
|
|
227
|
+
const expanded = fp + rest;
|
|
238
228
|
if (sectionIds.has(expanded.toLowerCase())) {
|
|
239
229
|
return { resolved: expanded, ambiguous: null, suggested: null };
|
|
240
230
|
}
|
|
231
|
+
// Try inserting root headings between file and rest.
|
|
232
|
+
// Handles Obsidian-style file#heading refs where the h1 is implicit.
|
|
233
|
+
const rootHeadings = findRootHeadings(fp, sectionIds);
|
|
234
|
+
for (const h1 of rootHeadings) {
|
|
235
|
+
const withRoot = rest ? `${fp}#${h1}${rest}` : `${fp}#${h1}`;
|
|
236
|
+
if (sectionIds.has(withRoot.toLowerCase())) {
|
|
237
|
+
return { resolved: withRoot, ambiguous: null, suggested: null };
|
|
238
|
+
}
|
|
239
|
+
}
|
|
241
240
|
}
|
|
242
|
-
else if (
|
|
241
|
+
else if (filePaths.length > 1) {
|
|
243
242
|
// Multiple files share this stem — ambiguous at the filename level
|
|
244
|
-
const all =
|
|
245
|
-
const valid =
|
|
243
|
+
const all = filePaths.map((c) => c + rest);
|
|
244
|
+
const valid = filePaths.filter((c) => {
|
|
245
|
+
if (sectionIds.has((c + rest).toLowerCase()))
|
|
246
|
+
return true;
|
|
247
|
+
// Also try with root heading insertion
|
|
248
|
+
const rootHeadings = findRootHeadings(c, sectionIds);
|
|
249
|
+
return rootHeadings.some((h1) => {
|
|
250
|
+
const withRoot = rest ? `${c}#${h1}${rest}` : `${c}#${h1}`;
|
|
251
|
+
return sectionIds.has(withRoot.toLowerCase());
|
|
252
|
+
});
|
|
253
|
+
});
|
|
246
254
|
return {
|
|
247
255
|
resolved: target,
|
|
248
256
|
ambiguous: all,
|
|
@@ -251,6 +259,20 @@ export function resolveRef(target, sectionIds, fileIndex) {
|
|
|
251
259
|
}
|
|
252
260
|
return { resolved: target, ambiguous: null, suggested: null };
|
|
253
261
|
}
|
|
262
|
+
/**
|
|
263
|
+
* Find root (h1) headings for a file by scanning sectionIds for entries
|
|
264
|
+
* that have exactly the pattern `file#heading` (no further # segments).
|
|
265
|
+
*/
|
|
266
|
+
function findRootHeadings(file, sectionIds) {
|
|
267
|
+
const prefix = file.toLowerCase() + '#';
|
|
268
|
+
const headings = [];
|
|
269
|
+
for (const id of sectionIds) {
|
|
270
|
+
if (id.startsWith(prefix) && !id.includes('#', prefix.length)) {
|
|
271
|
+
headings.push(id.slice(prefix.length));
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return headings;
|
|
275
|
+
}
|
|
254
276
|
const MAX_DISTANCE_RATIO = 0.4;
|
|
255
277
|
export function findSections(sections, query) {
|
|
256
278
|
const flat = flattenSections(sections);
|
|
@@ -266,6 +288,16 @@ export function findSections(sections, query) {
|
|
|
266
288
|
}));
|
|
267
289
|
if (exactMatches.length > 0 && isFullPath)
|
|
268
290
|
return exactMatches;
|
|
291
|
+
// Tier 1a: bare name matches file — return root sections of that file
|
|
292
|
+
if (!isFullPath && exactMatches.length === 0) {
|
|
293
|
+
const fileRoots = flat.filter((s) => s.file.toLowerCase() === q && !s.id.includes('#', s.file.length + 1));
|
|
294
|
+
if (fileRoots.length > 0) {
|
|
295
|
+
return fileRoots.map((s) => ({
|
|
296
|
+
section: s,
|
|
297
|
+
reason: 'exact match',
|
|
298
|
+
}));
|
|
299
|
+
}
|
|
300
|
+
}
|
|
269
301
|
// Tier 1b: file stem expansion
|
|
270
302
|
// For bare names: "locate" → matches root section of "tests/locate.md"
|
|
271
303
|
// For paths with #: "setup#Install" → expands to "guides/setup#Install"
|
|
@@ -276,26 +308,53 @@ export function findSections(sections, query) {
|
|
|
276
308
|
const hashIdx = normalized.indexOf('#');
|
|
277
309
|
const filePart = normalized.slice(0, hashIdx);
|
|
278
310
|
const rest = normalized.slice(hashIdx);
|
|
279
|
-
const
|
|
280
|
-
|
|
311
|
+
const stemPaths = fileIndex.get(filePart) ?? [];
|
|
312
|
+
// Also try filePart as a direct file path (for root-level files not in index)
|
|
313
|
+
const allPaths = stemPaths.length > 0 ? stemPaths : filePart ? [filePart] : [];
|
|
314
|
+
for (const p of allPaths) {
|
|
281
315
|
const expanded = (p + rest).toLowerCase();
|
|
282
316
|
const s = flat.find((s) => s.id.toLowerCase() === expanded && !exact.includes(s));
|
|
283
|
-
if (s)
|
|
317
|
+
if (s) {
|
|
284
318
|
stemMatches.push({
|
|
285
319
|
section: s,
|
|
286
|
-
reason:
|
|
320
|
+
reason: stemPaths.length > 0
|
|
321
|
+
? `file stem expanded: ${filePart} → ${p}`
|
|
322
|
+
: 'exact match',
|
|
287
323
|
});
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
// Try inserting root headings: file#rest → file#h1#rest
|
|
327
|
+
const rootsOfFile = flat.filter((s) => s.file.toLowerCase() === p.toLowerCase() &&
|
|
328
|
+
!s.id.includes('#', s.file.length + 1));
|
|
329
|
+
for (const root of rootsOfFile) {
|
|
330
|
+
const withRoot = (root.id + rest).toLowerCase();
|
|
331
|
+
const match = flat.find((s) => s.id.toLowerCase() === withRoot && !exact.includes(s));
|
|
332
|
+
if (match) {
|
|
333
|
+
stemMatches.push({
|
|
334
|
+
section: match,
|
|
335
|
+
reason: stemPaths.length > 0
|
|
336
|
+
? `file stem expanded: ${filePart} → ${p}`
|
|
337
|
+
: 'exact match',
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}
|
|
288
341
|
}
|
|
289
342
|
if (stemMatches.length > 0)
|
|
290
343
|
return [...exactMatches, ...stemMatches];
|
|
291
344
|
}
|
|
292
345
|
else {
|
|
293
|
-
// Bare name: match
|
|
346
|
+
// Bare name: match root sections of files via stem index
|
|
294
347
|
const paths = fileIndex.get(normalized) ?? [];
|
|
295
348
|
for (const p of paths) {
|
|
296
|
-
const s
|
|
297
|
-
|
|
298
|
-
|
|
349
|
+
for (const s of flat) {
|
|
350
|
+
if (exact.includes(s))
|
|
351
|
+
continue;
|
|
352
|
+
// Root sections have id = "file#heading" (exactly 2 segments)
|
|
353
|
+
if (s.file.toLowerCase() === p.toLowerCase() &&
|
|
354
|
+
!s.id.includes('#', s.file.length + 1)) {
|
|
355
|
+
stemMatches.push({ section: s, reason: 'file stem match' });
|
|
356
|
+
}
|
|
357
|
+
}
|
|
299
358
|
}
|
|
300
359
|
}
|
|
301
360
|
// Tier 2: exact match on trailing segments (subsection name match)
|
|
@@ -437,7 +496,7 @@ export function extractRefs(filePath, content, latticeDir) {
|
|
|
437
496
|
stack.pop();
|
|
438
497
|
}
|
|
439
498
|
const parent = stack.length > 0 ? stack[stack.length - 1] : null;
|
|
440
|
-
const id = parent ? `${parent.id}#${heading}` : file
|
|
499
|
+
const id = parent ? `${parent.id}#${heading}` : `${file}#${heading}`;
|
|
441
500
|
flat[idx].id = id;
|
|
442
501
|
stack.push({ id, depth });
|
|
443
502
|
idx++;
|
package/dist/src/walk.d.ts
CHANGED
|
@@ -2,10 +2,6 @@
|
|
|
2
2
|
* Walk a directory tree respecting .gitignore rules. Returns relative paths
|
|
3
3
|
* of all non-ignored files, excluding .git/ and dotfiles (e.g. .gitignore).
|
|
4
4
|
*
|
|
5
|
-
* Results are memoized by resolved directory path — safe because CLI commands
|
|
6
|
-
* don't modify the filesystem during a run. Set _LAT_TEST_DISABLE_FS_CACHE=1
|
|
7
|
-
* to bypass caching in tests that mutate the filesystem mid-run.
|
|
8
|
-
*
|
|
9
5
|
* This is the single entry point for all directory walking in lat.md — both
|
|
10
6
|
* code-ref scanning and lat.md/ index validation use it so .gitignore rules
|
|
11
7
|
* are consistently honored.
|
package/dist/src/walk.js
CHANGED
|
@@ -1,32 +1,16 @@
|
|
|
1
|
-
import { resolve } from 'node:path';
|
|
2
1
|
// @ts-expect-error -- no type declarations
|
|
3
2
|
import walk from 'ignore-walk';
|
|
4
|
-
const cache = new Map();
|
|
5
3
|
/**
|
|
6
4
|
* Walk a directory tree respecting .gitignore rules. Returns relative paths
|
|
7
5
|
* of all non-ignored files, excluding .git/ and dotfiles (e.g. .gitignore).
|
|
8
6
|
*
|
|
9
|
-
* Results are memoized by resolved directory path — safe because CLI commands
|
|
10
|
-
* don't modify the filesystem during a run. Set _LAT_TEST_DISABLE_FS_CACHE=1
|
|
11
|
-
* to bypass caching in tests that mutate the filesystem mid-run.
|
|
12
|
-
*
|
|
13
7
|
* This is the single entry point for all directory walking in lat.md — both
|
|
14
8
|
* code-ref scanning and lat.md/ index validation use it so .gitignore rules
|
|
15
9
|
* are consistently honored.
|
|
16
10
|
*/
|
|
17
11
|
export function walkEntries(dir) {
|
|
18
|
-
|
|
19
|
-
if (!noCache) {
|
|
20
|
-
const cached = cache.get(resolve(dir));
|
|
21
|
-
if (cached)
|
|
22
|
-
return cached;
|
|
23
|
-
}
|
|
24
|
-
const result = walk({
|
|
12
|
+
return walk({
|
|
25
13
|
path: dir,
|
|
26
14
|
ignoreFiles: ['.gitignore'],
|
|
27
15
|
}).then((entries) => entries.filter((e) => !e.startsWith('.git/') && !e.startsWith('.')));
|
|
28
|
-
if (!noCache) {
|
|
29
|
-
cache.set(resolve(dir), result);
|
|
30
|
-
}
|
|
31
|
-
return result;
|
|
32
16
|
}
|