@facetlayer/docs-tool 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.
@@ -0,0 +1,156 @@
1
+ ---
2
+ name: writing-doc-files
3
+ description: How to write and add documentation files for CLI tools
4
+ ---
5
+
6
+ # Writing Doc Files
7
+
8
+ This guide explains how to write documentation files that work with `@facetlayer/docs-tool`.
9
+
10
+ ## Doc File Format
11
+
12
+ Doc files are Markdown files with YAML frontmatter. The frontmatter provides metadata that's displayed when listing docs.
13
+
14
+ ### Basic Structure
15
+
16
+ ```markdown
17
+ ---
18
+ name: my-doc-name
19
+ description: A brief description of what this doc covers
20
+ ---
21
+
22
+ # Doc Title
23
+
24
+ Your markdown content here.
25
+ ```
26
+
27
+ ### Frontmatter Fields
28
+
29
+ | Field | Required | Description |
30
+ |-------|----------|-------------|
31
+ | `name` | Recommended | Short identifier for the doc (used in `get-doc` command) |
32
+ | `description` | Recommended | One-line description shown in `list-docs` output |
33
+
34
+ If `name` is not provided, the filename (without `.md`) is used as the name.
35
+
36
+ ### Example Doc File
37
+
38
+ ```markdown
39
+ ---
40
+ name: configuration
41
+ description: How to configure the application settings
42
+ ---
43
+
44
+ # Configuration Guide
45
+
46
+ ## Environment Variables
47
+
48
+ The app reads the following environment variables:
49
+
50
+ - `API_KEY` - Your API key for authentication
51
+ - `DEBUG` - Set to "true" to enable debug logging
52
+
53
+ ## Config File
54
+
55
+ Create a `config.json` in your project root:
56
+
57
+ \`\`\`json
58
+ {
59
+ "timeout": 30,
60
+ "retries": 3
61
+ }
62
+ \`\`\`
63
+ ```
64
+
65
+ ## Adding Docs to Your CLI
66
+
67
+ To ensure your doc files are available through the CLI, you need to:
68
+
69
+ 1. **Create a docs directory** - Typically `./docs` in your project root
70
+
71
+ 2. **Configure DocFilesHelper** - In your CLI script, create an instance pointing to your docs:
72
+
73
+ ```typescript
74
+ import { DocFilesHelper } from '@facetlayer/docs-tool';
75
+
76
+ const docFiles = new DocFilesHelper({
77
+ dirs: [join(__packageRoot, 'docs')],
78
+ files: [join(__packageRoot, 'README.md')], // Optional
79
+ });
80
+ ```
81
+
82
+ 3. **Verify the configuration** - Check that your docs are included by running:
83
+
84
+ ```bash
85
+ # List all available docs
86
+ your-cli list-docs
87
+
88
+ # Verify a specific doc works
89
+ your-cli get-doc your-doc-name
90
+ ```
91
+
92
+ ## Troubleshooting
93
+
94
+ ### Doc not showing in list-docs
95
+
96
+ 1. **Check the file location** - Ensure the file is in a directory listed in `dirs` or explicitly in `files`
97
+
98
+ 2. **Check the file extension** - Only `.md` files are included from directories
99
+
100
+ 3. **Verify DocFilesHelper setup** - Look for where `DocFilesHelper` is instantiated in your CLI code:
101
+
102
+ ```typescript
103
+ // Look for something like this in your cli.ts or main script:
104
+ const docFiles = new DocFilesHelper({
105
+ dirs: [...], // Your docs directory should be here
106
+ files: [...], // Or specific files listed here
107
+ });
108
+ ```
109
+
110
+ 4. **Check path resolution** - Make sure paths are absolute. Use the `__packageRoot` pattern:
111
+
112
+ ```typescript
113
+ import { dirname, join } from 'path';
114
+ import { fileURLToPath } from 'url';
115
+
116
+ const __filename = fileURLToPath(import.meta.url);
117
+ const __dirname = dirname(__filename);
118
+ const __packageRoot = join(__dirname, '..');
119
+
120
+ const docFiles = new DocFilesHelper({
121
+ dirs: [join(__packageRoot, 'docs')], // Absolute path
122
+ });
123
+ ```
124
+
125
+ Relative paths like `'./docs'` will resolve from the current working directory, not your package root, which breaks when the CLI is run from different directories
126
+
127
+ ### Doc content not displaying correctly
128
+
129
+ 1. **Check frontmatter syntax** - Ensure the `---` delimiters are on their own lines
130
+ 2. **Validate YAML** - Make sure frontmatter uses valid YAML (proper spacing, no tabs)
131
+
132
+ ## Publishing Docs with Your Package
133
+
134
+ If you publish your CLI to npm, make sure to include the docs directory in your `package.json`:
135
+
136
+ ```json
137
+ {
138
+ "files": [
139
+ "src",
140
+ "dist",
141
+ "docs",
142
+ "README.md"
143
+ ]
144
+ }
145
+ ```
146
+
147
+ Without this, the docs folder won't be included when users install your package, and `list-docs` will show nothing.
148
+
149
+ ## Best Practices
150
+
151
+ 1. **Use descriptive names** - Choose names that are easy to type and remember
152
+ 2. **Write clear descriptions** - The description appears in `list-docs` so make it helpful
153
+ 3. **Keep docs focused** - One topic per doc file
154
+ 4. **Include examples** - Show concrete usage examples in your docs
155
+ 5. **Test your docs** - Run `list-docs` and `get-doc` after adding new files
156
+ 6. **Include README.md** - Add your README to the `files` array so it shows in `list-docs`
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@facetlayer/docs-tool",
3
+ "version": "0.1.0",
4
+ "description": "Library and CLI tool for browsing docs in NPM packages",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "bin": {
9
+ "docs": "dist/cli.js"
10
+ },
11
+ "scripts": {
12
+ "build": "node build.mts build",
13
+ "prepublishOnly": "node build.mts validate && node build.mts build",
14
+ "typecheck": "tsc -p .",
15
+ "test": "vitest run --testTimeout=30000"
16
+ },
17
+ "keywords": [
18
+ "doc",
19
+ "frontmatter",
20
+ "markdown",
21
+ "documentation"
22
+ ],
23
+ "author": "andyfischer",
24
+ "license": "MIT",
25
+ "packageManager": "pnpm@10.15.1",
26
+ "files": [
27
+ "src",
28
+ "dist",
29
+ "docs",
30
+ "tsconfig.json",
31
+ "README.md"
32
+ ],
33
+ "dependencies": {
34
+ "@facetlayer/subprocess-wrapper": "^1.1.0",
35
+ "yargs": "18.0.0"
36
+ },
37
+ "devDependencies": {
38
+ "@facetlayer/build-config-nodejs": "0.3.1",
39
+ "@types/node": "^22.10.2",
40
+ "@types/yargs": "17.0.34",
41
+ "typescript": "^5.7.2",
42
+ "vitest": "^3.0.0"
43
+ }
44
+ }
@@ -0,0 +1,80 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { runShellCommand } from '@facetlayer/subprocess-wrapper';
3
+ import { join } from 'path';
4
+
5
+ const projectRoot = join(import.meta.dirname, '../..');
6
+ const cliPath = join(projectRoot, 'src/cli.ts');
7
+ const sampleDir = join(projectRoot, 'sample');
8
+
9
+ async function runCli(...args: string[]) {
10
+ return runShellCommand('npx', ['tsx', cliPath, ...args], {
11
+ cwd: projectRoot,
12
+ });
13
+ }
14
+
15
+ describe('CLI', () => {
16
+ describe('list command', () => {
17
+ it('should list all doc files in the sample directory', async () => {
18
+ const result = await runCli('list', sampleDir);
19
+
20
+ expect(result.exitCode).toBe(0);
21
+
22
+ const output = result.stdoutAsString();
23
+ expect(output).toContain('Available doc files:');
24
+ expect(output).toContain('getting-started.md');
25
+ expect(output).toContain('api-reference.md');
26
+ expect(output).toContain('configuration.md');
27
+ });
28
+
29
+ it('should show descriptions from frontmatter', async () => {
30
+ const result = await runCli('list', sampleDir);
31
+
32
+ expect(result.exitCode).toBe(0);
33
+
34
+ const output = result.stdoutAsString();
35
+ expect(output).toContain('Quick start guide for new users');
36
+ expect(output).toContain('Complete API documentation');
37
+ expect(output).toContain('Configuration options and settings');
38
+ });
39
+
40
+ it('should fail with non-existent directory', async () => {
41
+ const result = await runCli('list', './nonexistent-dir');
42
+
43
+ expect(result.exitCode).not.toBe(0);
44
+ });
45
+ });
46
+
47
+ describe('help and version', () => {
48
+ it('should show help with --help flag', async () => {
49
+ const result = await runCli('--help');
50
+
51
+ expect(result.exitCode).toBe(0);
52
+
53
+ const output = result.stdoutAsString();
54
+ expect(output).toContain('list');
55
+ });
56
+
57
+ it('should show version with --version flag', async () => {
58
+ const result = await runCli('--version');
59
+
60
+ expect(result.exitCode).toBe(0);
61
+
62
+ const output = result.stdoutAsString();
63
+ expect(output).toMatch(/\d+\.\d+\.\d+/);
64
+ });
65
+ });
66
+
67
+ describe('error handling', () => {
68
+ it('should require a command', async () => {
69
+ const result = await runCli();
70
+
71
+ expect(result.exitCode).not.toBe(0);
72
+ });
73
+
74
+ it('should reject unknown commands', async () => {
75
+ const result = await runCli('unknown-command');
76
+
77
+ expect(result.exitCode).not.toBe(0);
78
+ });
79
+ });
80
+ });
@@ -0,0 +1,333 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import { parseFrontmatter, DocFilesHelper } from '../index.ts';
3
+ import { mkdirSync, writeFileSync, rmSync } from 'fs';
4
+ import { join } from 'path';
5
+
6
+ describe('parseFrontmatter', () => {
7
+ it('should parse frontmatter with name and description', () => {
8
+ const input = `---
9
+ name: test-doc
10
+ description: A test document
11
+ ---
12
+
13
+ # Test Content
14
+
15
+ This is the body.`;
16
+
17
+ const result = parseFrontmatter(input);
18
+
19
+ expect(result.frontmatter.name).toBe('test-doc');
20
+ expect(result.frontmatter.description).toBe('A test document');
21
+ expect(result.content).toBe('# Test Content\n\nThis is the body.');
22
+ });
23
+
24
+ it('should parse frontmatter with custom fields', () => {
25
+ const input = `---
26
+ name: my-doc
27
+ author: John Doe
28
+ version: 1.0
29
+ ---
30
+
31
+ Content here`;
32
+
33
+ const result = parseFrontmatter(input);
34
+
35
+ expect(result.frontmatter.name).toBe('my-doc');
36
+ expect(result.frontmatter.author).toBe('John Doe');
37
+ expect(result.frontmatter.version).toBe('1.0');
38
+ });
39
+
40
+ it('should return empty frontmatter when none exists', () => {
41
+ const input = '# No Frontmatter\n\nJust content.';
42
+
43
+ const result = parseFrontmatter(input);
44
+
45
+ expect(result.frontmatter).toEqual({});
46
+ expect(result.content).toBe(input);
47
+ });
48
+
49
+ it('should handle frontmatter with colons in values', () => {
50
+ const input = `---
51
+ name: doc-name
52
+ description: This has: a colon in it
53
+ ---
54
+
55
+ Content`;
56
+
57
+ const result = parseFrontmatter(input);
58
+
59
+ expect(result.frontmatter.description).toBe('This has: a colon in it');
60
+ });
61
+
62
+ it('should handle Windows-style line endings', () => {
63
+ const input = '---\r\nname: test\r\ndescription: desc\r\n---\r\n\r\nContent';
64
+
65
+ const result = parseFrontmatter(input);
66
+
67
+ expect(result.frontmatter.name).toBe('test');
68
+ expect(result.frontmatter.description).toBe('desc');
69
+ expect(result.content).toBe('Content');
70
+ });
71
+
72
+ it('should handle empty content after frontmatter', () => {
73
+ const input = `---
74
+ name: empty
75
+ ---
76
+
77
+ `;
78
+
79
+ const result = parseFrontmatter(input);
80
+
81
+ expect(result.frontmatter.name).toBe('empty');
82
+ expect(result.content).toBe('');
83
+ });
84
+ });
85
+
86
+ describe('DocFilesHelper', () => {
87
+ const testDir = join(import.meta.dirname, '../../test/temp/docs');
88
+ const extraDir = join(import.meta.dirname, '../../test/temp/extra');
89
+ const standaloneFile = join(import.meta.dirname, '../../test/temp/standalone.md');
90
+
91
+ beforeAll(() => {
92
+ mkdirSync(testDir, { recursive: true });
93
+ mkdirSync(extraDir, { recursive: true });
94
+
95
+ writeFileSync(
96
+ join(testDir, 'first-doc.md'),
97
+ `---
98
+ name: first-doc
99
+ description: The first test doc
100
+ ---
101
+
102
+ # First Doc
103
+
104
+ Content of first doc.`
105
+ );
106
+
107
+ writeFileSync(
108
+ join(testDir, 'second-doc.md'),
109
+ `---
110
+ name: second-doc
111
+ description: The second test doc
112
+ ---
113
+
114
+ # Second Doc
115
+
116
+ Content of second doc.`
117
+ );
118
+
119
+ writeFileSync(
120
+ join(testDir, 'no-frontmatter.md'),
121
+ `# No Frontmatter
122
+
123
+ Just content without frontmatter.`
124
+ );
125
+
126
+ // Non-md file should be ignored
127
+ writeFileSync(join(testDir, 'ignored.txt'), 'This should be ignored');
128
+
129
+ // File in extra directory
130
+ writeFileSync(
131
+ join(extraDir, 'extra-doc.md'),
132
+ `---
133
+ name: extra-doc
134
+ description: An extra doc file
135
+ ---
136
+
137
+ # Extra Doc
138
+
139
+ Content of extra doc.`
140
+ );
141
+
142
+ // Standalone file outside of directories
143
+ writeFileSync(
144
+ standaloneFile,
145
+ `---
146
+ name: standalone
147
+ description: A standalone file
148
+ ---
149
+
150
+ # Standalone
151
+
152
+ Standalone content.`
153
+ );
154
+ });
155
+
156
+ afterAll(() => {
157
+ rmSync(join(import.meta.dirname, '../../test/temp'), { recursive: true, force: true });
158
+ });
159
+
160
+ describe('with dirs option', () => {
161
+ let helper: DocFilesHelper;
162
+
163
+ beforeAll(() => {
164
+ helper = new DocFilesHelper({ dirs: [testDir] });
165
+ });
166
+
167
+ describe('listDocs', () => {
168
+ it('should list all doc files with their metadata', () => {
169
+ const docs = helper.listDocs();
170
+
171
+ expect(docs).toHaveLength(3);
172
+
173
+ const firstDoc = docs.find((s) => s.name === 'first-doc');
174
+ expect(firstDoc).toBeDefined();
175
+ expect(firstDoc?.description).toBe('The first test doc');
176
+ expect(firstDoc?.filename).toBe('first-doc.md');
177
+
178
+ const secondDoc = docs.find((s) => s.name === 'second-doc');
179
+ expect(secondDoc).toBeDefined();
180
+ expect(secondDoc?.description).toBe('The second test doc');
181
+ });
182
+
183
+ it('should use filename as name when frontmatter has no name', () => {
184
+ const docs = helper.listDocs();
185
+
186
+ const noFrontmatter = docs.find((s) => s.filename === 'no-frontmatter.md');
187
+ expect(noFrontmatter?.name).toBe('no-frontmatter');
188
+ expect(noFrontmatter?.description).toBe('');
189
+ });
190
+
191
+ it('should ignore non-md files', () => {
192
+ const docs = helper.listDocs();
193
+
194
+ const txtFile = docs.find((s) => s.filename === 'ignored.txt');
195
+ expect(txtFile).toBeUndefined();
196
+ });
197
+ });
198
+
199
+ describe('getDoc', () => {
200
+ it('should return doc content by name', () => {
201
+ const doc = helper.getDoc('first-doc');
202
+
203
+ expect(doc.name).toBe('first-doc');
204
+ expect(doc.description).toBe('The first test doc');
205
+ expect(doc.content).toBe('# First Doc\n\nContent of first doc.');
206
+ expect(doc.rawContent).toContain('---');
207
+ });
208
+
209
+ it('should throw error for non-existent doc', () => {
210
+ expect(() => helper.getDoc('nonexistent')).toThrow('Doc file not found: nonexistent');
211
+ });
212
+
213
+ it('should handle doc without frontmatter', () => {
214
+ const doc = helper.getDoc('no-frontmatter');
215
+
216
+ expect(doc.name).toBe('no-frontmatter');
217
+ expect(doc.description).toBe('');
218
+ expect(doc.content).toContain('# No Frontmatter');
219
+ });
220
+
221
+ it('should handle name with .md extension', () => {
222
+ const doc = helper.getDoc('first-doc.md');
223
+
224
+ expect(doc.name).toBe('first-doc');
225
+ expect(doc.filename).toBe('first-doc.md');
226
+ });
227
+
228
+ it('should find doc by partial match', () => {
229
+ const doc = helper.getDoc('first');
230
+
231
+ expect(doc.name).toBe('first-doc');
232
+ expect(doc.filename).toBe('first-doc.md');
233
+ });
234
+
235
+ it('should throw error when multiple docs match', () => {
236
+ expect(() => helper.getDoc('doc')).toThrow(/Multiple docs match/);
237
+ });
238
+ });
239
+ });
240
+
241
+ describe('with multiple dirs', () => {
242
+ let helper: DocFilesHelper;
243
+
244
+ beforeAll(() => {
245
+ helper = new DocFilesHelper({ dirs: [testDir, extraDir] });
246
+ });
247
+
248
+ it('should list docs from all directories', () => {
249
+ const docs = helper.listDocs();
250
+
251
+ expect(docs).toHaveLength(4);
252
+ expect(docs.find((s) => s.name === 'first-doc')).toBeDefined();
253
+ expect(docs.find((s) => s.name === 'extra-doc')).toBeDefined();
254
+ });
255
+
256
+ it('should get doc from any directory', () => {
257
+ const doc = helper.getDoc('extra-doc');
258
+
259
+ expect(doc.name).toBe('extra-doc');
260
+ expect(doc.description).toBe('An extra doc file');
261
+ });
262
+ });
263
+
264
+ describe('with files option', () => {
265
+ let helper: DocFilesHelper;
266
+
267
+ beforeAll(() => {
268
+ helper = new DocFilesHelper({ files: [standaloneFile] });
269
+ });
270
+
271
+ it('should list standalone files', () => {
272
+ const docs = helper.listDocs();
273
+
274
+ expect(docs).toHaveLength(1);
275
+ expect(docs[0].name).toBe('standalone');
276
+ expect(docs[0].filename).toBe('standalone.md');
277
+ });
278
+
279
+ it('should get standalone file by base filename', () => {
280
+ const doc = helper.getDoc('standalone');
281
+
282
+ expect(doc.name).toBe('standalone');
283
+ expect(doc.description).toBe('A standalone file');
284
+ expect(doc.content).toBe('# Standalone\n\nStandalone content.');
285
+ });
286
+ });
287
+
288
+ describe('with non-existent files', () => {
289
+ it('should silently skip non-existent files in listDocs', () => {
290
+ const helper = new DocFilesHelper({
291
+ files: [
292
+ standaloneFile,
293
+ '/non/existent/file.md',
294
+ ],
295
+ });
296
+
297
+ const docs = helper.listDocs();
298
+
299
+ // Should only include the existing file, not throw an error
300
+ expect(docs).toHaveLength(1);
301
+ expect(docs[0].name).toBe('standalone');
302
+ });
303
+ });
304
+
305
+ describe('with both dirs and files', () => {
306
+ let helper: DocFilesHelper;
307
+
308
+ beforeAll(() => {
309
+ helper = new DocFilesHelper({
310
+ dirs: [testDir],
311
+ files: [standaloneFile],
312
+ });
313
+ });
314
+
315
+ it('should list docs from both dirs and files', () => {
316
+ const docs = helper.listDocs();
317
+
318
+ expect(docs).toHaveLength(4);
319
+ expect(docs.find((s) => s.name === 'first-doc')).toBeDefined();
320
+ expect(docs.find((s) => s.name === 'standalone')).toBeDefined();
321
+ });
322
+
323
+ it('should get doc from dirs', () => {
324
+ const doc = helper.getDoc('first-doc');
325
+ expect(doc.name).toBe('first-doc');
326
+ });
327
+
328
+ it('should get doc from files', () => {
329
+ const doc = helper.getDoc('standalone');
330
+ expect(doc.name).toBe('standalone');
331
+ });
332
+ });
333
+ });
@@ -0,0 +1,35 @@
1
+ import { resolve, join } from 'path';
2
+ import { existsSync } from 'fs';
3
+ import { DocFilesHelper } from './index.ts';
4
+
5
+ export interface LocalLibraryDocs {
6
+ libraryPath: string;
7
+ helper: DocFilesHelper;
8
+ hasDocsFolder: boolean;
9
+ }
10
+
11
+ /**
12
+ * Create a DocFilesHelper for browsing docs in a local directory.
13
+ * Looks for .md files in the target directory and also in a ./docs subdirectory if it exists.
14
+ */
15
+ export function browseLocalLibrary(targetPath: string): LocalLibraryDocs {
16
+ const resolvedPath = resolve(targetPath);
17
+ const docsPath = join(resolvedPath, 'docs');
18
+ const hasDocsFolder = existsSync(docsPath);
19
+
20
+ const dirs: string[] = [resolvedPath];
21
+ if (hasDocsFolder) {
22
+ dirs.push(docsPath);
23
+ }
24
+
25
+ const helper = new DocFilesHelper({
26
+ dirs,
27
+ overrideGetSubcommand: `show ${targetPath}`,
28
+ });
29
+
30
+ return {
31
+ libraryPath: resolvedPath,
32
+ helper,
33
+ hasDocsFolder,
34
+ };
35
+ }