dsh-plugin-wiki-tools 0.3.0 → 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/lib/lint.js +39 -9
- package/lib/search.js +13 -7
- package/lib/vault.js +65 -2
- package/package.json +1 -1
package/lib/lint.js
CHANGED
|
@@ -8,7 +8,14 @@
|
|
|
8
8
|
|
|
9
9
|
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
10
10
|
import { join } from 'node:path'
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
buildAliasMap,
|
|
13
|
+
collectMarkdown,
|
|
14
|
+
isMachineryPage,
|
|
15
|
+
resolveLinkTarget,
|
|
16
|
+
splitFrontmatter,
|
|
17
|
+
today,
|
|
18
|
+
} from './vault.js'
|
|
12
19
|
|
|
13
20
|
/** Frontmatter fields every content page must carry. */
|
|
14
21
|
const REQUIRED_FIELDS = ['type', 'status', 'created', 'updated', 'tags']
|
|
@@ -53,10 +60,9 @@ export async function lintVault(root) {
|
|
|
53
60
|
* @param {Add} add - issue recorder.
|
|
54
61
|
*/
|
|
55
62
|
function checkDuplicateFilenames(pages, add) {
|
|
56
|
-
const isMachinery = page => META_FILENAMES.has(page.name.toLowerCase()) || page.name.startsWith('_')
|
|
57
63
|
const seen = new Map()
|
|
58
64
|
for (const page of pages) {
|
|
59
|
-
if (
|
|
65
|
+
if (isMachineryPage(page.name)) continue
|
|
60
66
|
const key = page.name.toLowerCase()
|
|
61
67
|
if (seen.has(key)) {
|
|
62
68
|
add('duplicate-filename', 'error', page.name,
|
|
@@ -99,18 +105,29 @@ async function checkStaleIndexEntries(root, pages, add) {
|
|
|
99
105
|
* @param {Add} add - issue recorder.
|
|
100
106
|
* @returns {Map<string, string[]>} inbound links per page title.
|
|
101
107
|
*/
|
|
108
|
+
/**
|
|
109
|
+
* Wikilinks targeting pages that do not exist. Targets resolve through page
|
|
110
|
+
* titles first, then frontmatter `aliases` (Obsidian's order); only an
|
|
111
|
+
* unresolved target is dead.
|
|
112
|
+
* @param {Pages} pages - collected pages.
|
|
113
|
+
* @param {Add} add - issue recorder.
|
|
114
|
+
* @returns {Map<string, string[]>} inbound links per resolved page title.
|
|
115
|
+
*/
|
|
102
116
|
function checkDeadLinks(pages, add) {
|
|
103
117
|
const names = new Set(pages.map(page => page.name))
|
|
118
|
+
const aliases = buildAliasMap(pages)
|
|
104
119
|
const inbound = new Map(pages.map(page => [page.name, []]))
|
|
105
120
|
for (const page of pages) {
|
|
121
|
+
if (isMachineryPage(page.name)) continue
|
|
106
122
|
for (const target of page.links) {
|
|
107
|
-
|
|
108
|
-
|
|
123
|
+
const resolved = resolveLinkTarget(target, names, aliases)
|
|
124
|
+
if (resolved !== undefined) {
|
|
125
|
+
inbound.get(resolved)?.push(page.name)
|
|
109
126
|
continue
|
|
110
127
|
}
|
|
111
128
|
add('dead-link', 'error', page.name,
|
|
112
129
|
`links to [[${target}]] which does not exist`,
|
|
113
|
-
'Create a stub page or remove the link')
|
|
130
|
+
'Create a stub page, add the target to a page\u2019s aliases, or remove the link')
|
|
114
131
|
}
|
|
115
132
|
}
|
|
116
133
|
return inbound
|
|
@@ -126,7 +143,7 @@ function checkDeadLinks(pages, add) {
|
|
|
126
143
|
function checkOrphans(pages, inbound, indexed, add) {
|
|
127
144
|
for (const page of pages) {
|
|
128
145
|
const base = page.name.toLowerCase()
|
|
129
|
-
if (
|
|
146
|
+
if (isMachineryPage(page.name)) continue
|
|
130
147
|
const links = inbound.get(page.name) ?? []
|
|
131
148
|
const isIndexed = indexed.has(page.name)
|
|
132
149
|
if (links.length === 0 && !isIndexed) {
|
|
@@ -145,7 +162,7 @@ function checkOrphans(pages, inbound, indexed, add) {
|
|
|
145
162
|
function checkFrontmatterGaps(pages, add) {
|
|
146
163
|
for (const page of pages) {
|
|
147
164
|
const base = page.name.toLowerCase()
|
|
148
|
-
if (
|
|
165
|
+
if (isMachineryPage(page.name)) continue
|
|
149
166
|
const missing = REQUIRED_FIELDS.filter(field => page.fields?.[field] === undefined)
|
|
150
167
|
if (missing.length > 0) {
|
|
151
168
|
add('frontmatter-gap', 'warn', page.name,
|
|
@@ -162,12 +179,25 @@ function checkFrontmatterGaps(pages, add) {
|
|
|
162
179
|
*/
|
|
163
180
|
function checkEmptySections(pages, add) {
|
|
164
181
|
for (const page of pages) {
|
|
165
|
-
if (
|
|
182
|
+
if (isMachineryPage(page.name)) continue
|
|
183
|
+
// Fence-aware scan: lines inside code fences are content (a section whose
|
|
184
|
+
// body is only code is not empty) and heading-like comment lines inside
|
|
185
|
+
// fences never open sections.
|
|
166
186
|
const lines = [...page.content.split('\n'), '# __eof__ sentinel']
|
|
167
187
|
let heading
|
|
168
188
|
let headingLevel = 0
|
|
169
189
|
let hasContent = false
|
|
190
|
+
let inFence = false
|
|
170
191
|
for (const line of lines) {
|
|
192
|
+
if (line.startsWith('```')) {
|
|
193
|
+
inFence = !inFence
|
|
194
|
+
hasContent = true
|
|
195
|
+
continue
|
|
196
|
+
}
|
|
197
|
+
if (inFence) {
|
|
198
|
+
hasContent = true
|
|
199
|
+
continue
|
|
200
|
+
}
|
|
171
201
|
const match = /^(#{1,6}) (.+)$/.exec(line)
|
|
172
202
|
if (match !== null) {
|
|
173
203
|
const nextLevel = match[1].length
|
package/lib/search.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { readFile } from 'node:fs/promises'
|
|
10
10
|
import { join } from 'node:path'
|
|
11
|
-
import { collectMarkdown,
|
|
11
|
+
import { buildAliasMap, collectMarkdown, isMachineryPage, resolveLinkTarget } from './vault.js'
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Answer the quick mode: the hot cache and master index verbatim. The caller
|
|
@@ -40,20 +40,26 @@ export async function searchVault(root, { query, limit = 10 }) {
|
|
|
40
40
|
const pages = await collectMarkdown(join(root, 'wiki'))
|
|
41
41
|
// The link graph covers every file, but results exclude vault machinery:
|
|
42
42
|
// the index matches nearly every term by construction and is already the
|
|
43
|
-
// quick-mode payload
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
// quick-mode payload, and dated lint reports quote past wikilinks as records,
|
|
44
|
+
// not graph edges — so they neither appear as hits nor count as inbound.
|
|
45
|
+
const searchable = pages.filter(page => !isMachineryPage(page.name))
|
|
46
|
+
const names = new Set(pages.map(page => page.name))
|
|
47
|
+
const aliases = buildAliasMap(pages)
|
|
46
48
|
const inbound = new Map(pages.map(page => [page.name, []]))
|
|
47
49
|
for (const page of searchable) {
|
|
48
|
-
// One inbound record per source page, however many times it links
|
|
50
|
+
// One inbound record per source page, however many times it links; alias
|
|
51
|
+
// targets resolve to their owning page.
|
|
49
52
|
for (const target of new Set(page.links)) {
|
|
50
|
-
|
|
53
|
+
const resolved = resolveLinkTarget(target, names, aliases)
|
|
54
|
+
if (resolved !== undefined) inbound.get(resolved)?.push(page.name)
|
|
51
55
|
}
|
|
52
56
|
}
|
|
53
57
|
const results = []
|
|
54
58
|
let totalMatches = 0
|
|
55
59
|
for (const page of searchable) {
|
|
56
|
-
const
|
|
60
|
+
const aliasText = (page.aliases ?? []).join(' ').toLowerCase()
|
|
61
|
+
const titleHits = (countOccurrences(page.name.toLowerCase(), needle)
|
|
62
|
+
+ (aliasText.includes(needle) ? 1 : 0)) * 5
|
|
57
63
|
const tags = Array.isArray(page.fields?.tags) ? page.fields.tags.join(' ').toLowerCase() : ''
|
|
58
64
|
const tagHits = tags.includes(needle) ? 4 : 0
|
|
59
65
|
const headings = page.content.split('\n').filter(line => line.startsWith('#')).join('\n').toLowerCase()
|
package/lib/vault.js
CHANGED
|
@@ -382,23 +382,86 @@ export async function collectMarkdown(directory) {
|
|
|
382
382
|
fields,
|
|
383
383
|
content,
|
|
384
384
|
links: extractWikilinks(content),
|
|
385
|
+
aliases: frontmatterAliases(fields),
|
|
385
386
|
})
|
|
386
387
|
}
|
|
387
388
|
}
|
|
389
|
+
pages.sort((left, right) => left.name.localeCompare(right.name))
|
|
388
390
|
return pages
|
|
389
391
|
}
|
|
390
392
|
|
|
393
|
+
/**
|
|
394
|
+
* Read a page's declared `aliases` frontmatter field (Obsidian resolves these
|
|
395
|
+
* as link targets): a string or list of strings.
|
|
396
|
+
* @param {Record<string, unknown> | undefined} fields - parsed frontmatter.
|
|
397
|
+
* @returns {string[]} declared aliases, empty when absent or malformed.
|
|
398
|
+
*/
|
|
399
|
+
function frontmatterAliases(fields) {
|
|
400
|
+
const raw = fields?.aliases
|
|
401
|
+
if (typeof raw === 'string') return raw.trim().length > 0 ? [raw.trim()] : []
|
|
402
|
+
if (Array.isArray(raw)) return raw.filter(alias => typeof alias === 'string' && alias.trim().length > 0).map(alias => alias.trim())
|
|
403
|
+
return []
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Build the alias resolution map every link consumer shares: alias → page
|
|
408
|
+
* title. Earlier pages win a duplicate alias, deterministically, because
|
|
409
|
+
* {@link collectMarkdown} sorts by name.
|
|
410
|
+
* @param {{ name: string, aliases?: string[] }[]} pages - collected pages.
|
|
411
|
+
* @returns {Map<string, string>} alias → owning page title.
|
|
412
|
+
*/
|
|
413
|
+
export function buildAliasMap(pages) {
|
|
414
|
+
const map = new Map()
|
|
415
|
+
for (const page of pages) {
|
|
416
|
+
for (const alias of page.aliases ?? []) {
|
|
417
|
+
if (!map.has(alias)) map.set(alias, page.name)
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return map
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Resolve one wikilink target to a page title: direct name hit first, then the
|
|
425
|
+
* alias map (Obsidian's resolution order).
|
|
426
|
+
* @param {string} target - the raw link target.
|
|
427
|
+
* @param {Set<string>} names - every page title.
|
|
428
|
+
* @param {Map<string, string>} aliases - alias → page title.
|
|
429
|
+
* @returns {string | undefined} the resolved page title, or undefined when dead.
|
|
430
|
+
*/
|
|
431
|
+
export function resolveLinkTarget(target, names, aliases) {
|
|
432
|
+
if (names.has(target)) return target
|
|
433
|
+
return aliases.get(target)
|
|
434
|
+
}
|
|
435
|
+
|
|
391
436
|
/**
|
|
392
437
|
* Extract wikilink targets from markdown, dropping aliases and heading anchors.
|
|
438
|
+
* Fenced code blocks and inline code spans are not links: examples written as
|
|
439
|
+
* `` `[[Target]]` `` must not join the graph.
|
|
393
440
|
* @param {string} content - markdown body.
|
|
394
441
|
* @returns {string[]} link targets in order of appearance.
|
|
395
442
|
*/
|
|
396
443
|
export function extractWikilinks(content) {
|
|
397
444
|
const links = []
|
|
398
|
-
const
|
|
399
|
-
|
|
445
|
+
const withoutCode = content
|
|
446
|
+
.replace(/```[\s\S]*?```/g, '')
|
|
447
|
+
.replace(/`[^`\n]*`/g, '')
|
|
448
|
+
for (const match of withoutCode.matchAll(/\[\[([^\]]+)\]\]/g)) {
|
|
400
449
|
const target = match[1].split('|')[0].split('#')[0].trim()
|
|
401
450
|
if (target.length > 0) links.push(target)
|
|
402
451
|
}
|
|
403
452
|
return links
|
|
404
453
|
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Whether a page basename is vault machinery rather than content: the index,
|
|
457
|
+
* log, hot cache, overview, per-folder sub-indexes, and dated lint reports.
|
|
458
|
+
* Lint reports quote past issues verbatim, so their wikilinks are records, not
|
|
459
|
+
* graph edges.
|
|
460
|
+
* @param {string} name - page basename without extension.
|
|
461
|
+
* @returns {boolean} whether the page is machinery.
|
|
462
|
+
*/
|
|
463
|
+
export function isMachineryPage(name) {
|
|
464
|
+
return META_FILENAMES.has(name.toLowerCase())
|
|
465
|
+
|| name.startsWith('_')
|
|
466
|
+
|| /^lint-report-/.test(name)
|
|
467
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-wiki-tools",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Native DeepSeek Harness tools for an Obsidian wiki vault: wiki_query, wiki_write, and wiki_lint implement the mechanical core (path routing, delta tracking, index/log bookkeeping, health checks) of the wiki skill suite.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|