lat.md 0.4.3 → 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.
@@ -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 `- **name** — description` */
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 = /^- \*\*(.+?)\*\*/gm;
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) => `- **${e}** — <describe>`).join('\n');
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 (!children.includes(name) && name !== indexFileName) {
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
  }
@@ -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 };
@@ -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,
@@ -218,17 +218,39 @@ export function resolveRef(target, sectionIds, fileIndex) {
218
218
  const hashIdx = target.indexOf('#');
219
219
  const filePart = hashIdx === -1 ? target : target.slice(0, hashIdx);
220
220
  const rest = hashIdx === -1 ? '' : target.slice(hashIdx);
221
- const candidates = fileIndex.get(filePart) ?? [];
222
- if (candidates.length === 1) {
223
- const expanded = candidates[0] + rest;
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;
224
228
  if (sectionIds.has(expanded.toLowerCase())) {
225
229
  return { resolved: expanded, ambiguous: null, suggested: null };
226
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
+ }
227
240
  }
228
- else if (candidates.length > 1) {
241
+ else if (filePaths.length > 1) {
229
242
  // Multiple files share this stem — ambiguous at the filename level
230
- const all = candidates.map((c) => c + rest);
231
- const valid = candidates.filter((c) => sectionIds.has((c + rest).toLowerCase()));
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
+ });
232
254
  return {
233
255
  resolved: target,
234
256
  ambiguous: all,
@@ -237,6 +259,20 @@ export function resolveRef(target, sectionIds, fileIndex) {
237
259
  }
238
260
  return { resolved: target, ambiguous: null, suggested: null };
239
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
+ }
240
276
  const MAX_DISTANCE_RATIO = 0.4;
241
277
  export function findSections(sections, query) {
242
278
  const flat = flattenSections(sections);
@@ -252,6 +288,16 @@ export function findSections(sections, query) {
252
288
  }));
253
289
  if (exactMatches.length > 0 && isFullPath)
254
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
+ }
255
301
  // Tier 1b: file stem expansion
256
302
  // For bare names: "locate" → matches root section of "tests/locate.md"
257
303
  // For paths with #: "setup#Install" → expands to "guides/setup#Install"
@@ -262,26 +308,53 @@ export function findSections(sections, query) {
262
308
  const hashIdx = normalized.indexOf('#');
263
309
  const filePart = normalized.slice(0, hashIdx);
264
310
  const rest = normalized.slice(hashIdx);
265
- const paths = fileIndex.get(filePart) ?? [];
266
- for (const p of paths) {
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) {
267
315
  const expanded = (p + rest).toLowerCase();
268
316
  const s = flat.find((s) => s.id.toLowerCase() === expanded && !exact.includes(s));
269
- if (s)
317
+ if (s) {
270
318
  stemMatches.push({
271
319
  section: s,
272
- reason: `file stem expanded: ${filePart} → ${p}`,
320
+ reason: stemPaths.length > 0
321
+ ? `file stem expanded: ${filePart} → ${p}`
322
+ : 'exact match',
273
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
+ }
274
341
  }
275
342
  if (stemMatches.length > 0)
276
343
  return [...exactMatches, ...stemMatches];
277
344
  }
278
345
  else {
279
- // Bare name: match file root sections via stem index
346
+ // Bare name: match root sections of files via stem index
280
347
  const paths = fileIndex.get(normalized) ?? [];
281
348
  for (const p of paths) {
282
- const s = flat.find((s) => s.id.toLowerCase() === p.toLowerCase() && !exact.includes(s));
283
- if (s)
284
- stemMatches.push({ section: s, reason: 'file stem match' });
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
+ }
285
358
  }
286
359
  }
287
360
  // Tier 2: exact match on trailing segments (subsection name match)
@@ -423,7 +496,7 @@ export function extractRefs(filePath, content, latticeDir) {
423
496
  stack.pop();
424
497
  }
425
498
  const parent = stack.length > 0 ? stack[stack.length - 1] : null;
426
- const id = parent ? `${parent.id}#${heading}` : file;
499
+ const id = parent ? `${parent.id}#${heading}` : `${file}#${heading}`;
427
500
  flat[idx].id = id;
428
501
  stack.push({ id, depth });
429
502
  idx++;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lat.md",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "description": "A knowledge graph for your codebase, written in markdown",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.30.2",