@henryavila/mdprobe 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/README.md +404 -0
- package/bin/cli.js +335 -0
- package/dist/assets/index-DPysqH1p.js +2 -0
- package/dist/assets/index-nl9v2RuJ.css +1 -0
- package/dist/index.html +19 -0
- package/package.json +75 -0
- package/schema.json +104 -0
- package/skills/mdprobe/SKILL.md +358 -0
- package/src/anchoring.js +262 -0
- package/src/annotations.js +504 -0
- package/src/cli-utils.js +58 -0
- package/src/config.js +76 -0
- package/src/export.js +211 -0
- package/src/handler.js +229 -0
- package/src/hash.js +51 -0
- package/src/renderer.js +247 -0
- package/src/server.js +849 -0
- package/src/ui/app.jsx +152 -0
- package/src/ui/components/AnnotationForm.jsx +72 -0
- package/src/ui/components/Content.jsx +334 -0
- package/src/ui/components/ExportMenu.jsx +62 -0
- package/src/ui/components/LeftPanel.jsx +99 -0
- package/src/ui/components/Popover.jsx +94 -0
- package/src/ui/components/ReplyThread.jsx +28 -0
- package/src/ui/components/RightPanel.jsx +171 -0
- package/src/ui/components/SectionApproval.jsx +31 -0
- package/src/ui/components/ThemePicker.jsx +18 -0
- package/src/ui/hooks/useAnnotations.js +160 -0
- package/src/ui/hooks/useClientLibs.js +97 -0
- package/src/ui/hooks/useKeyboard.js +128 -0
- package/src/ui/hooks/useTheme.js +57 -0
- package/src/ui/hooks/useWebSocket.js +126 -0
- package/src/ui/index.html +19 -0
- package/src/ui/state/store.js +76 -0
- package/src/ui/styles/themes.css +1243 -0
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
import yaml from 'js-yaml'
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
4
|
+
|
|
5
|
+
const VALID_TAGS = ['bug', 'question', 'suggestion', 'nitpick']
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Validates that a tag is one of the allowed values.
|
|
9
|
+
* @param {string} tag
|
|
10
|
+
* @throws {Error} if tag is not in VALID_TAGS
|
|
11
|
+
*/
|
|
12
|
+
function validateTag(tag) {
|
|
13
|
+
if (!VALID_TAGS.includes(tag)) {
|
|
14
|
+
throw new Error(`Invalid tag "${tag}". Must be one of: ${VALID_TAGS.join(', ')}`)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Generates a short unique identifier.
|
|
20
|
+
* @returns {string}
|
|
21
|
+
*/
|
|
22
|
+
function generateId() {
|
|
23
|
+
return randomUUID().replace(/-/g, '').slice(0, 8)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Manages annotation data for a Markdown source file.
|
|
28
|
+
*
|
|
29
|
+
* Supports CRUD operations on annotations, section approval tracking,
|
|
30
|
+
* persistence to/from YAML, and export to JSON and SARIF formats.
|
|
31
|
+
*/
|
|
32
|
+
export class AnnotationFile {
|
|
33
|
+
/**
|
|
34
|
+
* @param {object} data - Raw annotation file data
|
|
35
|
+
* @param {number} data.version
|
|
36
|
+
* @param {string} data.source
|
|
37
|
+
* @param {string} data.source_hash
|
|
38
|
+
* @param {Array} data.annotations
|
|
39
|
+
* @param {Array} data.sections
|
|
40
|
+
*/
|
|
41
|
+
constructor(data) {
|
|
42
|
+
this.version = data.version
|
|
43
|
+
this.source = data.source
|
|
44
|
+
this.sourceHash = data.source_hash
|
|
45
|
+
this.annotations = data.annotations ?? []
|
|
46
|
+
this.sections = data.sections ?? []
|
|
47
|
+
|
|
48
|
+
// Ensure every annotation has a replies array
|
|
49
|
+
for (const ann of this.annotations) {
|
|
50
|
+
if (!ann.replies) {
|
|
51
|
+
ann.replies = []
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Static factory methods
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Creates a new empty AnnotationFile for a given source.
|
|
62
|
+
* @param {string} source - Source markdown filename
|
|
63
|
+
* @param {string} sourceHash - Hash of the source file (e.g. "sha256:abc123")
|
|
64
|
+
* @returns {AnnotationFile}
|
|
65
|
+
*/
|
|
66
|
+
static create(source, sourceHash) {
|
|
67
|
+
return new AnnotationFile({
|
|
68
|
+
version: 1,
|
|
69
|
+
source,
|
|
70
|
+
source_hash: sourceHash,
|
|
71
|
+
annotations: [],
|
|
72
|
+
sections: [],
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Loads an AnnotationFile from a YAML file on disk.
|
|
78
|
+
* @param {string} yamlPath - Absolute path to the YAML file
|
|
79
|
+
* @returns {Promise<AnnotationFile>}
|
|
80
|
+
* @throws {Error} if the file does not exist or contains invalid YAML
|
|
81
|
+
*/
|
|
82
|
+
static async load(yamlPath) {
|
|
83
|
+
const content = await readFile(yamlPath, 'utf-8')
|
|
84
|
+
|
|
85
|
+
let data
|
|
86
|
+
try {
|
|
87
|
+
data = yaml.load(content)
|
|
88
|
+
} catch (err) {
|
|
89
|
+
// js-yaml errors include a `mark` with line info; re-throw with line context
|
|
90
|
+
if (err.mark != null) {
|
|
91
|
+
throw new Error(`Invalid YAML at line ${err.mark.line + 1}: ${err.message}`)
|
|
92
|
+
}
|
|
93
|
+
throw new Error(`Invalid YAML (line unknown): ${err.message}`)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return new AnnotationFile({
|
|
97
|
+
version: data.version,
|
|
98
|
+
source: data.source,
|
|
99
|
+
source_hash: data.source_hash,
|
|
100
|
+
annotations: data.annotations ?? [],
|
|
101
|
+
sections: data.sections ?? [],
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// CRUD — annotations
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Adds a new annotation.
|
|
111
|
+
* @param {object} opts
|
|
112
|
+
* @param {object} opts.selectors - Position/quote selectors (required)
|
|
113
|
+
* @param {string} opts.comment - Annotation text (required)
|
|
114
|
+
* @param {string} opts.tag - One of VALID_TAGS
|
|
115
|
+
* @param {string} opts.author - Author name
|
|
116
|
+
* @returns {object} the created annotation
|
|
117
|
+
*/
|
|
118
|
+
add({ selectors, comment, tag, author }) {
|
|
119
|
+
if (!selectors) {
|
|
120
|
+
throw new Error('selectors is required')
|
|
121
|
+
}
|
|
122
|
+
if (!comment) {
|
|
123
|
+
throw new Error('comment is required')
|
|
124
|
+
}
|
|
125
|
+
validateTag(tag)
|
|
126
|
+
|
|
127
|
+
const now = new Date().toISOString()
|
|
128
|
+
const annotation = {
|
|
129
|
+
id: generateId(),
|
|
130
|
+
selectors,
|
|
131
|
+
comment,
|
|
132
|
+
tag,
|
|
133
|
+
status: 'open',
|
|
134
|
+
author,
|
|
135
|
+
created_at: now,
|
|
136
|
+
updated_at: now,
|
|
137
|
+
replies: [],
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
this.annotations.push(annotation)
|
|
141
|
+
return annotation
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Resolves an annotation by id. Idempotent.
|
|
146
|
+
* @param {string} id
|
|
147
|
+
* @throws {Error} if annotation not found
|
|
148
|
+
*/
|
|
149
|
+
resolve(id) {
|
|
150
|
+
const ann = this._findOrThrow(id)
|
|
151
|
+
ann.status = 'resolved'
|
|
152
|
+
ann.updated_at = new Date().toISOString()
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Reopens a previously resolved annotation.
|
|
157
|
+
* @param {string} id
|
|
158
|
+
* @throws {Error} if annotation not found
|
|
159
|
+
*/
|
|
160
|
+
reopen(id) {
|
|
161
|
+
const ann = this._findOrThrow(id)
|
|
162
|
+
ann.status = 'open'
|
|
163
|
+
ann.updated_at = new Date().toISOString()
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Updates the comment text of an annotation.
|
|
168
|
+
* @param {string} id
|
|
169
|
+
* @param {string} text - New comment (must be non-empty)
|
|
170
|
+
* @throws {Error} if id not found or text is empty
|
|
171
|
+
*/
|
|
172
|
+
updateComment(id, text) {
|
|
173
|
+
if (!text) {
|
|
174
|
+
throw new Error('comment text must not be empty')
|
|
175
|
+
}
|
|
176
|
+
const ann = this._findOrThrow(id)
|
|
177
|
+
ann.comment = text
|
|
178
|
+
ann.updated_at = new Date().toISOString()
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Updates the tag of an annotation.
|
|
183
|
+
* @param {string} id
|
|
184
|
+
* @param {string} tag - New tag (must be valid)
|
|
185
|
+
* @throws {Error} if id not found or tag is invalid
|
|
186
|
+
*/
|
|
187
|
+
updateTag(id, tag) {
|
|
188
|
+
validateTag(tag)
|
|
189
|
+
const ann = this._findOrThrow(id)
|
|
190
|
+
ann.tag = tag
|
|
191
|
+
ann.updated_at = new Date().toISOString()
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Deletes an annotation by id.
|
|
196
|
+
* @param {string} id
|
|
197
|
+
* @throws {Error} if annotation not found
|
|
198
|
+
*/
|
|
199
|
+
delete(id) {
|
|
200
|
+
const index = this.annotations.findIndex(a => a.id === id)
|
|
201
|
+
if (index === -1) {
|
|
202
|
+
throw new Error(`Annotation "${id}" not found`)
|
|
203
|
+
}
|
|
204
|
+
this.annotations.splice(index, 1)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Adds a reply to an existing annotation.
|
|
209
|
+
* @param {string} annotationId
|
|
210
|
+
* @param {object} opts
|
|
211
|
+
* @param {string} opts.author
|
|
212
|
+
* @param {string} opts.comment
|
|
213
|
+
* @throws {Error} if annotation not found
|
|
214
|
+
*/
|
|
215
|
+
addReply(annotationId, { author, comment }) {
|
|
216
|
+
const ann = this._findOrThrow(annotationId)
|
|
217
|
+
ann.replies.push({
|
|
218
|
+
author,
|
|
219
|
+
comment,
|
|
220
|
+
created_at: new Date().toISOString(),
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// Query methods
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Retrieves a single annotation by id.
|
|
230
|
+
* @param {string} id
|
|
231
|
+
* @returns {object}
|
|
232
|
+
* @throws {Error} if not found
|
|
233
|
+
*/
|
|
234
|
+
getById(id) {
|
|
235
|
+
return this._findOrThrow(id)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Returns all open annotations.
|
|
240
|
+
* @returns {Array}
|
|
241
|
+
*/
|
|
242
|
+
getOpen() {
|
|
243
|
+
return this.annotations.filter(a => a.status === 'open')
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Returns all resolved annotations.
|
|
248
|
+
* @returns {Array}
|
|
249
|
+
*/
|
|
250
|
+
getResolved() {
|
|
251
|
+
return this.annotations.filter(a => a.status === 'resolved')
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Returns annotations matching a given tag.
|
|
256
|
+
* @param {string} tag
|
|
257
|
+
* @returns {Array}
|
|
258
|
+
*/
|
|
259
|
+
getByTag(tag) {
|
|
260
|
+
return this.annotations.filter(a => a.tag === tag)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Returns annotations by a given author.
|
|
265
|
+
* @param {string} author
|
|
266
|
+
* @returns {Array}
|
|
267
|
+
*/
|
|
268
|
+
getByAuthor(author) {
|
|
269
|
+
return this.annotations.filter(a => a.author === author)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
// Section approval
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Sets a section's status to 'approved'.
|
|
278
|
+
* @param {string} heading
|
|
279
|
+
* @throws {Error} if section not found
|
|
280
|
+
*/
|
|
281
|
+
approveSection(heading) {
|
|
282
|
+
this._cascadeStatus(heading, 'approved')
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Set status on a section and cascade to all descendants.
|
|
287
|
+
* @param {string} heading
|
|
288
|
+
* @param {string} status
|
|
289
|
+
*/
|
|
290
|
+
_cascadeStatus(heading, status) {
|
|
291
|
+
const target = this._findSectionOrThrow(heading)
|
|
292
|
+
target.status = status
|
|
293
|
+
if (target.level != null) {
|
|
294
|
+
const idx = this.sections.indexOf(target)
|
|
295
|
+
for (let i = idx + 1; i < this.sections.length; i++) {
|
|
296
|
+
if (this.sections[i].level == null || this.sections[i].level <= target.level) break
|
|
297
|
+
this.sections[i].status = status
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Compute effective status for each section considering children.
|
|
304
|
+
* Returns a new array with { heading, level, status, computed } where
|
|
305
|
+
* `computed` is 'indeterminate' when children have mixed statuses.
|
|
306
|
+
*/
|
|
307
|
+
computeStatus() {
|
|
308
|
+
return computeSectionStatus(this.sections)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Sets a section's status to 'rejected'. Cascades to descendants.
|
|
313
|
+
* @param {string} heading
|
|
314
|
+
* @throws {Error} if section not found
|
|
315
|
+
*/
|
|
316
|
+
rejectSection(heading) {
|
|
317
|
+
this._cascadeStatus(heading, 'rejected')
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Resets a section's status to 'pending'. Cascades to descendants.
|
|
322
|
+
* @param {string} heading
|
|
323
|
+
* @throws {Error} if section not found
|
|
324
|
+
*/
|
|
325
|
+
resetSection(heading) {
|
|
326
|
+
this._cascadeStatus(heading, 'pending')
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Sets all sections to 'approved'.
|
|
331
|
+
*/
|
|
332
|
+
approveAll() {
|
|
333
|
+
for (const section of this.sections) {
|
|
334
|
+
section.status = 'approved'
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Sets all sections to 'pending'.
|
|
340
|
+
*/
|
|
341
|
+
clearAll() {
|
|
342
|
+
for (const section of this.sections) {
|
|
343
|
+
section.status = 'pending'
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
// Persistence & export
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Saves the annotation file to disk as human-readable YAML.
|
|
353
|
+
* @param {string} yamlPath - Absolute path for the output file
|
|
354
|
+
* @returns {Promise<void>}
|
|
355
|
+
*/
|
|
356
|
+
async save(yamlPath) {
|
|
357
|
+
const data = this.toJSON()
|
|
358
|
+
const content = yaml.dump(data, {
|
|
359
|
+
lineWidth: -1,
|
|
360
|
+
noRefs: true,
|
|
361
|
+
sortKeys: false,
|
|
362
|
+
})
|
|
363
|
+
await writeFile(yamlPath, content, 'utf-8')
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Returns a plain JSON-serializable object representation.
|
|
368
|
+
* Uses underscore `source_hash` per spec.
|
|
369
|
+
* @returns {object}
|
|
370
|
+
*/
|
|
371
|
+
toJSON() {
|
|
372
|
+
return {
|
|
373
|
+
version: this.version,
|
|
374
|
+
source: this.source,
|
|
375
|
+
source_hash: this.sourceHash,
|
|
376
|
+
sections: this.sections,
|
|
377
|
+
annotations: this.annotations.map(ann => ({
|
|
378
|
+
id: ann.id,
|
|
379
|
+
selectors: ann.selectors,
|
|
380
|
+
comment: ann.comment,
|
|
381
|
+
tag: ann.tag,
|
|
382
|
+
status: ann.status,
|
|
383
|
+
author: ann.author,
|
|
384
|
+
created_at: ann.created_at,
|
|
385
|
+
updated_at: ann.updated_at,
|
|
386
|
+
replies: ann.replies ?? [],
|
|
387
|
+
})),
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Returns a SARIF 2.1.0 object with open annotations as results.
|
|
393
|
+
* @returns {object}
|
|
394
|
+
*/
|
|
395
|
+
toSARIF() {
|
|
396
|
+
const openAnnotations = this.getOpen()
|
|
397
|
+
|
|
398
|
+
return {
|
|
399
|
+
$schema:
|
|
400
|
+
'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json',
|
|
401
|
+
version: '2.1.0',
|
|
402
|
+
runs: [
|
|
403
|
+
{
|
|
404
|
+
tool: {
|
|
405
|
+
driver: {
|
|
406
|
+
name: 'mdprobe',
|
|
407
|
+
version: '0.1.0',
|
|
408
|
+
informationUri: 'https://github.com/henryavila/mdprobe',
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
results: openAnnotations.map(ann => {
|
|
412
|
+
const result = {
|
|
413
|
+
ruleId: ann.tag,
|
|
414
|
+
level: ann.tag === 'bug' ? 'error' : 'note',
|
|
415
|
+
message: { text: ann.comment },
|
|
416
|
+
locations: [],
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (ann.selectors?.position) {
|
|
420
|
+
const pos = ann.selectors.position
|
|
421
|
+
result.locations.push({
|
|
422
|
+
physicalLocation: {
|
|
423
|
+
artifactLocation: { uri: this.source },
|
|
424
|
+
region: {
|
|
425
|
+
startLine: pos.startLine,
|
|
426
|
+
...(pos.startColumn != null && { startColumn: pos.startColumn }),
|
|
427
|
+
...(pos.endLine != null && { endLine: pos.endLine }),
|
|
428
|
+
...(pos.endColumn != null && { endColumn: pos.endColumn }),
|
|
429
|
+
},
|
|
430
|
+
},
|
|
431
|
+
})
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return result
|
|
435
|
+
}),
|
|
436
|
+
},
|
|
437
|
+
],
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ---------------------------------------------------------------------------
|
|
442
|
+
// Internal helpers
|
|
443
|
+
// ---------------------------------------------------------------------------
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Finds an annotation by id or throws.
|
|
447
|
+
* @param {string} id
|
|
448
|
+
* @returns {object}
|
|
449
|
+
* @private
|
|
450
|
+
*/
|
|
451
|
+
_findOrThrow(id) {
|
|
452
|
+
const ann = this.annotations.find(a => a.id === id)
|
|
453
|
+
if (!ann) {
|
|
454
|
+
throw new Error(`Annotation "${id}" not found`)
|
|
455
|
+
}
|
|
456
|
+
return ann
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Finds a section by heading or throws.
|
|
461
|
+
* @param {string} heading
|
|
462
|
+
* @returns {object}
|
|
463
|
+
* @private
|
|
464
|
+
*/
|
|
465
|
+
_findSectionOrThrow(heading) {
|
|
466
|
+
const section = this.sections.find(s => s.heading === heading)
|
|
467
|
+
if (!section) {
|
|
468
|
+
throw new Error(`Section "${heading}" not found`)
|
|
469
|
+
}
|
|
470
|
+
return section
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Compute effective status for each section considering children.
|
|
476
|
+
* Walks bottom-up so children are resolved before parents.
|
|
477
|
+
* @param {Array<{heading: string, level?: number, status: string}>} sections
|
|
478
|
+
* @returns {Array<{heading: string, level?: number, status: string, computed: string}>}
|
|
479
|
+
*/
|
|
480
|
+
export function computeSectionStatus(sections) {
|
|
481
|
+
const result = sections.map(s => ({ ...s, computed: s.status }))
|
|
482
|
+
|
|
483
|
+
for (let i = result.length - 1; i >= 0; i--) {
|
|
484
|
+
const section = result[i]
|
|
485
|
+
if (section.level == null) continue
|
|
486
|
+
|
|
487
|
+
const children = []
|
|
488
|
+
for (let j = i + 1; j < result.length; j++) {
|
|
489
|
+
if (result[j].level == null || result[j].level <= section.level) break
|
|
490
|
+
children.push(result[j])
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (children.length === 0) continue
|
|
494
|
+
|
|
495
|
+
const statuses = new Set(children.map(c => c.computed))
|
|
496
|
+
if (statuses.size === 1) {
|
|
497
|
+
section.computed = [...statuses][0]
|
|
498
|
+
} else {
|
|
499
|
+
section.computed = 'indeterminate'
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return result
|
|
504
|
+
}
|
package/src/cli-utils.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs'
|
|
2
|
+
import { join, extname } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Find .md files in a directory (recursive, synchronous).
|
|
6
|
+
* Returns an empty array if directory doesn't exist or can't be read.
|
|
7
|
+
*
|
|
8
|
+
* @param {string} dir - Absolute directory path
|
|
9
|
+
* @returns {string[]} Sorted absolute paths
|
|
10
|
+
*/
|
|
11
|
+
export function findMarkdownFiles(dir) {
|
|
12
|
+
try {
|
|
13
|
+
const entries = readdirSync(dir, { withFileTypes: true, recursive: true })
|
|
14
|
+
return entries
|
|
15
|
+
.filter((e) => e.isFile() && extname(e.name).toLowerCase() === '.md')
|
|
16
|
+
.map((e) => join(e.parentPath || e.path, e.name))
|
|
17
|
+
.sort()
|
|
18
|
+
} catch {
|
|
19
|
+
return []
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Extract a flag and its value from an args array.
|
|
25
|
+
* Mutates the array by removing the flag (and its value if present).
|
|
26
|
+
*
|
|
27
|
+
* @param {string[]} args - Mutable args array
|
|
28
|
+
* @param {string} flag - Flag name (e.g., '--port')
|
|
29
|
+
* @returns {string|true|undefined} The value, `true` if flag has no value, `undefined` if absent
|
|
30
|
+
*/
|
|
31
|
+
export function extractFlag(args, flag) {
|
|
32
|
+
const idx = args.indexOf(flag)
|
|
33
|
+
if (idx === -1) return undefined
|
|
34
|
+
args.splice(idx, 1)
|
|
35
|
+
if (idx < args.length && !args[idx]?.startsWith('-')) {
|
|
36
|
+
return args.splice(idx, 1)[0]
|
|
37
|
+
}
|
|
38
|
+
return true
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Check if any of the given flags exist in the args array.
|
|
43
|
+
* Removes the first match found. Mutates the array.
|
|
44
|
+
*
|
|
45
|
+
* @param {string[]} args - Mutable args array
|
|
46
|
+
* @param {...string} flags - Flag names to check
|
|
47
|
+
* @returns {boolean}
|
|
48
|
+
*/
|
|
49
|
+
export function hasFlag(args, ...flags) {
|
|
50
|
+
for (const flag of flags) {
|
|
51
|
+
const idx = args.indexOf(flag)
|
|
52
|
+
if (idx !== -1) {
|
|
53
|
+
args.splice(idx, 1)
|
|
54
|
+
return true
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return false
|
|
58
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
|
|
5
|
+
const DEFAULT_CONFIG_PATH = join(homedir(), '.mdprobe.json')
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Read and parse the config file. Returns {} if file does not exist.
|
|
9
|
+
* Throws on malformed JSON.
|
|
10
|
+
*
|
|
11
|
+
* @param {string} [configPath]
|
|
12
|
+
* @returns {Promise<object>}
|
|
13
|
+
*/
|
|
14
|
+
export async function getConfig(configPath = DEFAULT_CONFIG_PATH) {
|
|
15
|
+
let raw
|
|
16
|
+
try {
|
|
17
|
+
raw = await readFile(configPath, 'utf8')
|
|
18
|
+
} catch (err) {
|
|
19
|
+
if (err.code === 'ENOENT') return {}
|
|
20
|
+
throw err
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(raw)
|
|
25
|
+
} catch (err) {
|
|
26
|
+
throw new Error(`Failed to parse JSON in ${configPath}: ${err.message}`)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Set a single key in the config file (read-modify-write).
|
|
32
|
+
* Creates the file and parent directories if they don't exist.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} key
|
|
35
|
+
* @param {*} value
|
|
36
|
+
* @param {string} [configPath]
|
|
37
|
+
* @returns {Promise<void>}
|
|
38
|
+
*/
|
|
39
|
+
export async function setConfig(key, value, configPath = DEFAULT_CONFIG_PATH) {
|
|
40
|
+
await mkdir(dirname(configPath), { recursive: true })
|
|
41
|
+
|
|
42
|
+
let config = {}
|
|
43
|
+
try {
|
|
44
|
+
const raw = await readFile(configPath, 'utf8')
|
|
45
|
+
config = JSON.parse(raw)
|
|
46
|
+
} catch {
|
|
47
|
+
// File missing or unreadable — start fresh
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
config[key] = value
|
|
51
|
+
await writeFile(configPath, JSON.stringify(config), 'utf8')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Get the configured author name.
|
|
56
|
+
* Returns "anonymous" when the config file is missing, or the author
|
|
57
|
+
* key is absent, empty, or null.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} [configPath]
|
|
60
|
+
* @returns {Promise<string>}
|
|
61
|
+
*/
|
|
62
|
+
export async function getAuthor(configPath = DEFAULT_CONFIG_PATH) {
|
|
63
|
+
let config
|
|
64
|
+
try {
|
|
65
|
+
config = await getConfig(configPath)
|
|
66
|
+
} catch {
|
|
67
|
+
return 'anonymous'
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const author = config.author
|
|
71
|
+
if (author == null || typeof author !== 'string' || author.trim() === '') {
|
|
72
|
+
return 'anonymous'
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return author.trim()
|
|
76
|
+
}
|