compmark-vue 0.2.6 → 0.3.1

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 CHANGED
@@ -9,17 +9,105 @@
9
9
 
10
10
  Auto-generate Markdown documentation from Vue 3 SFCs. Zero configuration required.
11
11
 
12
+ ## Installation
13
+
14
+ ```sh
15
+ # npm
16
+ npm install -D compmark-vue
17
+
18
+ # pnpm
19
+ pnpm add -D compmark-vue
20
+
21
+ # yarn
22
+ yarn add -D compmark-vue
23
+ ```
24
+
25
+ > Requires Node.js >= 20
26
+
12
27
  ## Quick Start
13
28
 
29
+ Document a single component:
30
+
14
31
  ```sh
15
32
  npx compmark-vue ./src/components/Button.vue
16
33
  ```
17
34
 
18
- This parses the component and creates `Button.md` in your current directory.
35
+ Document an entire directory:
36
+
37
+ ```sh
38
+ npx compmark-vue ./src/components --out ./docs/api
39
+ ```
40
+
41
+ Add to your `package.json`:
42
+
43
+ ```json
44
+ {
45
+ "scripts": {
46
+ "docs": "compmark ./src/components --out ./docs/api"
47
+ }
48
+ }
49
+ ```
50
+
51
+ ## CLI
52
+
53
+ ```
54
+ compmark <files/dirs/globs> [options]
55
+ ```
56
+
57
+ | Option | Description | Default |
58
+ | --------------------- | ------------------------------- | ------- |
59
+ | `--out <dir>` | Output directory | `.` |
60
+ | `--format <md\|json>` | Output format | `md` |
61
+ | `--join` | Combine into a single file | |
62
+ | `--ignore <patterns>` | Comma-separated ignore patterns | |
63
+ | `--watch` | Watch for changes and rebuild | |
64
+ | `--silent` | Suppress non-error output | |
65
+
66
+ ### Examples
67
+
68
+ ```sh
69
+ # Single file
70
+ compmark Button.vue
71
+
72
+ # Directory (recursive)
73
+ compmark src/components --out docs/api
74
+
75
+ # Glob pattern
76
+ compmark "src/**/components/*.vue" --out docs
77
+
78
+ # Monorepo
79
+ compmark "packages/*/src/components" --out docs/api
80
+
81
+ # Combined markdown with table of contents
82
+ compmark src/components --out docs --join
83
+
84
+ # JSON output
85
+ compmark src/components --out docs/api --format json
86
+
87
+ # JSON combined into single file
88
+ compmark src/components --format json --join --out docs
89
+
90
+ # Ignore patterns
91
+ compmark src/components --ignore "internal,*.test"
92
+
93
+ # Watch mode
94
+ compmark src/components --out docs --watch
95
+
96
+ # Multiple inputs
97
+ compmark src/components src/layouts --out docs
98
+ ```
99
+
100
+ The summary line shows what happened:
101
+
102
+ ```
103
+ ✓ 24 components documented, 2 skipped, 0 errors
104
+ ```
105
+
106
+ Exit code is `1` when errors occur (except in watch mode).
19
107
 
20
108
  ## Features
21
109
 
22
- - [Props](#props) — runtime and TypeScript generic syntax
110
+ - [Props](#props) — runtime and TypeScript generic syntax, including imported types
23
111
  - [Emits](#emits) — array, TypeScript property, and call signature syntax
24
112
  - [Slots](#slots) — `defineSlots` with typed bindings, template `<slot>` fallback
25
113
  - [Expose](#expose) — `defineExpose` with JSDoc descriptions
@@ -27,6 +115,7 @@ This parses the component and creates `Button.md` in your current directory.
27
115
  - [JSDoc tags](#jsdoc-tags) — `@deprecated`, `@since`, `@example`, `@see`, `@default`
28
116
  - [`@internal`](#internal-components) — exclude components from output
29
117
  - [Options API](#options-api) — `export default { props, emits }` support
118
+ - [Output formats](#output-formats) — Markdown (individual or joined), JSON
30
119
  - Empty sections are skipped cleanly — no placeholder noise
31
120
 
32
121
  ## Examples
@@ -83,6 +172,29 @@ defineProps({
83
172
  </script>
84
173
  ```
85
174
 
175
+ #### Imported types
176
+
177
+ `defineProps<ImportedType>()` with exported interfaces or type aliases is supported:
178
+
179
+ ```ts
180
+ // types.ts
181
+ export interface ButtonProps {
182
+ /** The label text */
183
+ label: string;
184
+ disabled?: boolean;
185
+ }
186
+ ```
187
+
188
+ ```vue
189
+ <script setup lang="ts">
190
+ import type { ButtonProps } from "./types";
191
+
192
+ defineProps<ButtonProps>();
193
+ </script>
194
+ ```
195
+
196
+ Interface `extends` is resolved (up to 5 levels deep). `withDefaults` works with imported types too.
197
+
86
198
  ### Emits
87
199
 
88
200
  TypeScript generic syntax with payloads:
@@ -186,15 +298,18 @@ Output:
186
298
 
187
299
  ### Composables
188
300
 
189
- Any `useX()` calls in `<script setup>` are automatically detected:
301
+ Any `useX()` calls in `<script setup>` are automatically detected. Variable bindings (simple assignment, object/array destructuring, rest elements) are extracted:
190
302
 
191
303
  ```vue
192
304
  <script setup lang="ts">
193
305
  import { useRouter } from "vue-router";
194
306
  import { useMouse } from "@vueuse/core";
307
+ import { useAuth } from "./composables/useAuth";
195
308
 
196
309
  const router = useRouter();
197
310
  const { x, y } = useMouse();
311
+ const { user, login, logout } = useAuth();
312
+ useHead({ title: "My App" });
198
313
  </script>
199
314
  ```
200
315
 
@@ -203,10 +318,31 @@ Output:
203
318
  ```md
204
319
  ## Composables Used
205
320
 
206
- - `useRouter`
207
- - `useMouse`
321
+ ### `useRouter`
322
+
323
+ **Returns:** `router`
324
+
325
+ ### `useMouse`
326
+
327
+ **Returns:** `x`, `y`
328
+
329
+ ### `useAuth`
330
+
331
+ _Source: `./composables/useAuth`_
332
+
333
+ | Variable | Type |
334
+ | -------- | ------------------------------------------- |
335
+ | user | Ref<User> |
336
+ | login | (credentials: Credentials) => Promise<void> |
337
+ | logout | () => void |
338
+
339
+ ### `useHead`
340
+
341
+ Called for side effects.
208
342
  ```
209
343
 
344
+ For local imports (`./` or `@/` paths), types are automatically resolved from the composable source file — `ref()`, `computed()`, `reactive()`, function signatures, and literals are all inferred. Source attribution is shown for local imports only.
345
+
210
346
  ### JSDoc Tags
211
347
 
212
348
  Props support `@deprecated`, `@since`, `@example`, and `@see`:
@@ -259,7 +395,8 @@ defineProps<{
259
395
 
260
396
  ```sh
261
397
  $ compmark InternalHelper.vue
262
- Skipped InternalHelper.vue (marked @internal)
398
+ Skipped InternalHelper.vue (marked @internal)
399
+ ✓ 0 components documented, 1 skipped, 0 errors
263
400
  ```
264
401
 
265
402
  ### Options API
@@ -303,6 +440,36 @@ Output:
303
440
  | update | - |
304
441
  ```
305
442
 
443
+ ### Output Formats
444
+
445
+ **Individual markdown** (default) — one `.md` file per component:
446
+
447
+ ```sh
448
+ compmark src/components --out docs
449
+ # Creates: docs/Button.md, docs/Dialog.md, ...
450
+ ```
451
+
452
+ **Joined markdown** — single file with table of contents:
453
+
454
+ ```sh
455
+ compmark src/components --out docs --join
456
+ # Creates: docs/components.md
457
+ ```
458
+
459
+ The joined file includes a generated timestamp, table of contents with anchor links, and all components with headings bumped one level.
460
+
461
+ **JSON** — machine-readable output:
462
+
463
+ ```sh
464
+ # Individual JSON files
465
+ compmark src/components --out docs --format json
466
+ # Creates: docs/Button.json, docs/Dialog.json, ...
467
+
468
+ # Combined JSON
469
+ compmark src/components --format json --join --out docs
470
+ # Creates: docs/components.json with { generated, components: [...] }
471
+ ```
472
+
306
473
  ## Programmatic API
307
474
 
308
475
  ```sh
@@ -325,6 +492,16 @@ const doc = parseSFC(source, "Button.vue");
325
492
  const md = generateMarkdown(doc);
326
493
  ```
327
494
 
495
+ Multi-file processing:
496
+
497
+ ```ts
498
+ import { discoverFiles, processFiles } from "compmark-vue";
499
+
500
+ const files = await discoverFiles(["src/components"], ["dist"]);
501
+ const summary = processFiles(files, { silent: false });
502
+ // summary.files, summary.documented, summary.skipped, summary.errors
503
+ ```
504
+
328
505
  ## Development
329
506
 
330
507
  <details>
@@ -332,7 +509,7 @@ const md = generateMarkdown(doc);
332
509
  <summary>local development</summary>
333
510
 
334
511
  - Clone this repository
335
- - Install latest LTS version of [Node.js](https://nodejs.org/en/)
512
+ - Install latest LTS version of [Node.js](https://nodejs.org/en/) (>= 20)
336
513
  - Enable [Corepack](https://github.com/nodejs/corepack) using `corepack enable`
337
514
  - Install dependencies using `pnpm install`
338
515
  - Run interactive tests using `pnpm dev`
package/dist/cli.mjs CHANGED
@@ -1,7 +1,47 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
+ import { defineCommand, runMain } from "citty";
3
+ import { existsSync, mkdirSync, readFileSync, statSync, watch, writeFileSync } from "node:fs";
3
4
  import { dirname, join, resolve } from "node:path";
5
+ import { glob } from "tinyglobby";
4
6
  import { babelParse, compileScript, parse } from "@vue/compiler-sfc";
7
+ //#region src/discovery.ts
8
+ async function discoverFiles(inputs, ignore) {
9
+ const baseIgnore = ["**/node_modules/**"];
10
+ const userIgnore = (ignore ?? []).map(normalizeIgnorePattern);
11
+ const allIgnore = [...baseIgnore, ...userIgnore];
12
+ const found = /* @__PURE__ */ new Set();
13
+ for (const input of inputs) {
14
+ const resolved = resolve(input);
15
+ if (input.endsWith(".vue") && existsSync(resolved)) found.add(resolved);
16
+ else if (isDirectory$1(resolved)) {
17
+ const files = await glob("**/*.vue", {
18
+ cwd: resolved,
19
+ absolute: true,
20
+ ignore: allIgnore
21
+ });
22
+ for (const f of files) found.add(f);
23
+ } else {
24
+ const files = await glob(input, {
25
+ absolute: true,
26
+ ignore: allIgnore
27
+ });
28
+ for (const f of files) found.add(f);
29
+ }
30
+ }
31
+ return [...found].sort();
32
+ }
33
+ function isDirectory$1(path) {
34
+ try {
35
+ return statSync(path).isDirectory();
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+ function normalizeIgnorePattern(pattern) {
41
+ if (pattern.includes("*") || pattern.includes("/")) return pattern;
42
+ return `**/${pattern}/**`;
43
+ }
44
+ //#endregion
5
45
  //#region src/resolver.ts
6
46
  function resolveImportPath(importSource, sfcDir) {
7
47
  try {
@@ -265,6 +305,56 @@ function resolveTypeAnnotation(node) {
265
305
  }
266
306
  }
267
307
  //#endregion
308
+ //#region src/type-resolver.ts
309
+ function resolveImportedPropsType(typeName, importMap, sfcDir) {
310
+ const source = importMap.get(typeName);
311
+ if (!source) return null;
312
+ const resolvedPath = resolveImportPath(source, sfcDir);
313
+ if (!resolvedPath) return null;
314
+ try {
315
+ return findExportedType(babelParse(readFileSync(resolvedPath, "utf-8"), {
316
+ plugins: ["typescript"],
317
+ sourceType: "module"
318
+ }).program.body, typeName, 0);
319
+ } catch {
320
+ return null;
321
+ }
322
+ }
323
+ function findExportedType(stmts, typeName, depth) {
324
+ if (depth > 5) return null;
325
+ for (const stmt of stmts) {
326
+ if (stmt.type === "ExportNamedDeclaration" && stmt.declaration?.type === "TSInterfaceDeclaration" && stmt.declaration.id.name === typeName) return resolveInterfaceMembers(stmt.declaration, stmts, depth);
327
+ if (stmt.type === "ExportNamedDeclaration" && stmt.declaration?.type === "TSTypeAliasDeclaration" && stmt.declaration.id.name === typeName && stmt.declaration.typeAnnotation.type === "TSTypeLiteral") return { members: [...stmt.declaration.typeAnnotation.members] };
328
+ }
329
+ if (hasNamedExport(stmts, typeName)) return findTypeInFile(stmts, typeName, depth);
330
+ return null;
331
+ }
332
+ function findTypeInFile(stmts, typeName, depth) {
333
+ if (depth > 5) return null;
334
+ for (const stmt of stmts) {
335
+ if (stmt.type === "ExportNamedDeclaration" && stmt.declaration?.type === "TSInterfaceDeclaration" && stmt.declaration.id.name === typeName) return resolveInterfaceMembers(stmt.declaration, stmts, depth);
336
+ if (stmt.type === "ExportNamedDeclaration" && stmt.declaration?.type === "TSTypeAliasDeclaration" && stmt.declaration.id.name === typeName && stmt.declaration.typeAnnotation.type === "TSTypeLiteral") return { members: [...stmt.declaration.typeAnnotation.members] };
337
+ if (stmt.type === "TSInterfaceDeclaration" && stmt.id.name === typeName) return resolveInterfaceMembers(stmt, stmts, depth);
338
+ if (stmt.type === "TSTypeAliasDeclaration" && stmt.id.name === typeName && stmt.typeAnnotation.type === "TSTypeLiteral") return { members: [...stmt.typeAnnotation.members] };
339
+ }
340
+ return null;
341
+ }
342
+ function resolveInterfaceMembers(decl, stmts, depth) {
343
+ const members = [];
344
+ if (decl.extends) for (const ext of decl.extends) {
345
+ const parentName = ext.expression?.type === "Identifier" ? ext.expression.name : null;
346
+ if (parentName) {
347
+ const parent = findTypeInFile(stmts, parentName, depth + 1);
348
+ if (parent) members.push(...parent.members);
349
+ }
350
+ }
351
+ members.push(...decl.body.body);
352
+ return { members };
353
+ }
354
+ function hasNamedExport(stmts, name) {
355
+ return stmts.some((s) => s.type === "ExportNamedDeclaration" && !s.declaration && s.specifiers.some((spec) => spec.type === "ExportSpecifier" && (spec.local.type === "Identifier" && spec.local.name === name || spec.exported.type === "Identifier" && spec.exported.name === name)));
356
+ }
357
+ //#endregion
268
358
  //#region src/parser.ts
269
359
  function parseJSDocTags(comments) {
270
360
  const result = { description: "" };
@@ -291,29 +381,55 @@ function parseSFC(source, filename, sfcDir) {
291
381
  props: [],
292
382
  emits: []
293
383
  };
294
- const { descriptor } = parse(source, { filename });
384
+ const fullPath = sfcDir ? `${sfcDir}/${filename}` : filename;
385
+ const { descriptor } = parse(source, { filename: fullPath });
386
+ doc.scriptSetup = !!descriptor.scriptSetup;
295
387
  if (descriptor.template?.ast) {
296
388
  const templateSlots = extractTemplateSlots(descriptor.template.ast);
297
389
  if (templateSlots.length > 0) doc.slots = templateSlots;
298
390
  }
299
391
  if (!descriptor.scriptSetup && !descriptor.script) return doc;
300
- const compiled = compileScript(descriptor, { id: filename });
392
+ let compiled;
393
+ try {
394
+ compiled = compileScript(descriptor, {
395
+ id: fullPath,
396
+ fs: {
397
+ fileExists: (file) => existsSync(file),
398
+ readFile: (file) => {
399
+ try {
400
+ return readFileSync(file, "utf-8");
401
+ } catch {
402
+ return;
403
+ }
404
+ }
405
+ }
406
+ });
407
+ } catch {
408
+ return doc;
409
+ }
301
410
  const componentJSDoc = extractComponentJSDoc(compiled.scriptSetupAst ?? compiled.scriptAst ?? []);
302
411
  doc.description = componentJSDoc.description;
303
412
  doc.internal = componentJSDoc.internal;
304
413
  const setupAst = compiled.scriptSetupAst;
305
414
  if (setupAst) {
306
415
  const scriptSource = descriptor.scriptSetup?.content ?? compiled.content;
416
+ const importMap = buildImportMap(setupAst);
307
417
  for (const stmt of setupAst) {
308
418
  const calls = extractDefineCalls(stmt);
309
419
  for (const { callee, args, leadingComments, typeParams, defaultsArg } of calls) if (callee === "defineProps" && args[0]?.type === "ObjectExpression") doc.props = extractProps(args[0], scriptSource);
310
420
  else if (callee === "defineProps" && typeParams?.params[0]?.type === "TSTypeLiteral") doc.props = extractTypeProps(typeParams.params[0], defaultsArg, scriptSource);
311
- else if (callee === "defineEmits" && args[0]?.type === "ArrayExpression") doc.emits = extractEmits(args[0], leadingComments);
421
+ else if (callee === "defineProps" && typeParams?.params[0]?.type === "TSTypeReference") {
422
+ const typeName = typeParams.params[0].typeName?.name;
423
+ if (typeName && sfcDir) {
424
+ const resolved = resolveImportedPropsType(typeName, importMap, sfcDir);
425
+ if (resolved) doc.props = extractTypeProps(resolved, defaultsArg, scriptSource);
426
+ }
427
+ } else if (callee === "defineEmits" && args[0]?.type === "ArrayExpression") doc.emits = extractEmits(args[0], leadingComments);
312
428
  else if (callee === "defineEmits" && typeParams?.params[0]?.type === "TSTypeLiteral") doc.emits = extractTypeEmits(typeParams.params[0]);
313
429
  else if (callee === "defineSlots" && typeParams?.params[0]?.type === "TSTypeLiteral") doc.slots = extractTypeSlots(typeParams.params[0]);
314
430
  else if (callee === "defineExpose" && args[0]?.type === "ObjectExpression") doc.exposes = extractExposes(args[0], scriptSource);
315
431
  }
316
- doc.composables = extractComposables(setupAst, buildImportMap(setupAst), sfcDir);
432
+ doc.composables = extractComposables(setupAst, importMap, sfcDir);
317
433
  }
318
434
  const scriptAst = compiled.scriptAst;
319
435
  if (scriptAst && doc.props.length === 0 && doc.emits.length === 0) {
@@ -785,6 +901,7 @@ function escHtml(value) {
785
901
  function generateMarkdown(doc) {
786
902
  const sections = [`# ${doc.name}`];
787
903
  if (doc.description) sections.push("", doc.description);
904
+ if (doc.scriptSetup) sections.push("", "**Note:** Uses `<script setup>` syntax.");
788
905
  const hasProps = doc.props.length > 0;
789
906
  const hasEmits = doc.emits.length > 0;
790
907
  const hasSlots = (doc.slots?.length ?? 0) > 0;
@@ -873,6 +990,12 @@ function generateMarkdown(doc) {
873
990
  }
874
991
  return sections.join("\n") + "\n";
875
992
  }
993
+ function adjustHeadingLevel(md, increment) {
994
+ return md.replace(/^(#{1,6})\s/gm, (_, hashes) => {
995
+ const newLevel = Math.min(hashes.length + increment, 6);
996
+ return "#".repeat(newLevel) + " ";
997
+ });
998
+ }
876
999
  //#endregion
877
1000
  //#region src/index.ts
878
1001
  function parseComponent(filePath) {
@@ -880,37 +1003,239 @@ function parseComponent(filePath) {
880
1003
  return parseSFC(readFileSync(abs, "utf-8"), abs.split("/").pop() ?? "Unknown.vue", abs.substring(0, abs.lastIndexOf("/")));
881
1004
  }
882
1005
  //#endregion
883
- //#region src/cli.ts
884
- const filePath = process.argv[2];
885
- if (!filePath) {
886
- console.error("Usage: compmark <path-to-component.vue>");
887
- process.exit(1);
888
- }
889
- if (!filePath.endsWith(".vue")) {
890
- console.error(`Error: Expected a .vue file, got: ${filePath}`);
891
- process.exit(1);
892
- }
893
- const abs = resolve(filePath);
894
- if (!existsSync(abs)) {
895
- console.error(`Error: File not found: ${filePath}`);
896
- process.exit(1);
897
- }
898
- try {
899
- const doc = parseComponent(abs);
900
- if (doc.internal) {
901
- const name = abs.split("/").pop() ?? filePath;
902
- console.log(`Skipped ${name} (marked @internal)`);
1006
+ //#region src/runner.ts
1007
+ function processFiles(filePaths, options) {
1008
+ const summary = {
1009
+ documented: 0,
1010
+ skipped: 0,
1011
+ errors: 0,
1012
+ files: [],
1013
+ errorDetails: []
1014
+ };
1015
+ for (const filePath of filePaths) try {
1016
+ const doc = parseComponent(filePath);
1017
+ if (doc.internal) {
1018
+ summary.skipped++;
1019
+ if (!options.silent) {
1020
+ const name = filePath.split("/").pop() ?? filePath;
1021
+ console.log(` Skipped ${name} (marked @internal)`);
1022
+ }
1023
+ continue;
1024
+ }
1025
+ summary.documented++;
1026
+ summary.files.push({
1027
+ path: filePath,
1028
+ doc
1029
+ });
1030
+ } catch (err) {
1031
+ summary.errors++;
1032
+ const name = filePath.split("/").pop() ?? filePath;
1033
+ const message = err instanceof Error ? err.message : String(err);
1034
+ summary.errorDetails.push({
1035
+ path: filePath,
1036
+ error: message
1037
+ });
1038
+ if (!options.silent) console.warn(` Warning: Could not parse ${name}: ${message}`);
1039
+ }
1040
+ return summary;
1041
+ }
1042
+ //#endregion
1043
+ //#region src/output.ts
1044
+ function writeIndividualMarkdown(results, outDir, silent) {
1045
+ mkdirSync(outDir, { recursive: true });
1046
+ const usedNames = /* @__PURE__ */ new Map();
1047
+ for (const { doc } of results) {
1048
+ const baseName = doc.name;
1049
+ const count = usedNames.get(baseName) ?? 0;
1050
+ usedNames.set(baseName, count + 1);
1051
+ const fileName = count === 0 ? baseName : `${baseName}-${count + 1}`;
1052
+ const md = generateMarkdown(doc);
1053
+ writeFileSync(join(outDir, `${fileName}.md`), md, "utf-8");
1054
+ if (!silent) console.log(` Created ${fileName}.md`);
1055
+ }
1056
+ }
1057
+ function writeJoinedMarkdown(results, outDir, silent) {
1058
+ mkdirSync(outDir, { recursive: true });
1059
+ const sections = [];
1060
+ sections.push("# Component Documentation");
1061
+ sections.push("");
1062
+ sections.push(`*Generated: ${(/* @__PURE__ */ new Date()).toISOString()}*`);
1063
+ sections.push("");
1064
+ sections.push("## Table of Contents");
1065
+ sections.push("");
1066
+ for (const { doc } of results) {
1067
+ const anchor = doc.name.toLowerCase().replace(/[^a-z0-9-]/g, "-");
1068
+ sections.push(`- [${doc.name}](#${anchor})`);
1069
+ }
1070
+ for (const { doc } of results) {
1071
+ const adjusted = adjustHeadingLevel(generateMarkdown(doc), 1);
1072
+ sections.push("");
1073
+ sections.push("---");
1074
+ sections.push("");
1075
+ sections.push(adjusted.trimEnd());
1076
+ }
1077
+ writeFileSync(join(outDir, "components.md"), sections.join("\n") + "\n", "utf-8");
1078
+ if (!silent) console.log(` Created components.md`);
1079
+ }
1080
+ function writeJSON(results, outDir, joined, silent) {
1081
+ mkdirSync(outDir, { recursive: true });
1082
+ if (joined) {
1083
+ const data = {
1084
+ generated: (/* @__PURE__ */ new Date()).toISOString(),
1085
+ components: results.map((r) => r.doc)
1086
+ };
1087
+ writeFileSync(join(outDir, "components.json"), JSON.stringify(data, null, 2) + "\n", "utf-8");
1088
+ if (!silent) console.log(` Created components.json`);
1089
+ } else {
1090
+ const usedNames = /* @__PURE__ */ new Map();
1091
+ for (const { doc } of results) {
1092
+ const baseName = doc.name;
1093
+ const count = usedNames.get(baseName) ?? 0;
1094
+ usedNames.set(baseName, count + 1);
1095
+ const fileName = count === 0 ? baseName : `${baseName}-${count + 1}`;
1096
+ writeFileSync(join(outDir, `${fileName}.json`), JSON.stringify(doc, null, 2) + "\n", "utf-8");
1097
+ if (!silent) console.log(` Created ${fileName}.json`);
1098
+ }
1099
+ }
1100
+ }
1101
+ //#endregion
1102
+ //#region src/watcher.ts
1103
+ function startWatcher(inputs, ignore, rebuild) {
1104
+ const roots = /* @__PURE__ */ new Set();
1105
+ for (const input of inputs) if (input.endsWith(".vue")) roots.add(dirname(resolve(input)));
1106
+ else roots.add(resolve(input));
1107
+ let timer = null;
1108
+ const debounce = () => {
1109
+ if (timer) clearTimeout(timer);
1110
+ timer = setTimeout(() => {
1111
+ console.log("[watch] Rebuilding...");
1112
+ try {
1113
+ rebuild();
1114
+ } catch (err) {
1115
+ const msg = err instanceof Error ? err.message : String(err);
1116
+ console.error(`[watch] Error: ${msg}`);
1117
+ }
1118
+ console.log("[watch] Done.");
1119
+ }, 300);
1120
+ };
1121
+ const watchers = [];
1122
+ for (const root of roots) try {
1123
+ const watcher = watch(root, { recursive: true }, (_event, filename) => {
1124
+ if (!filename || !filename.endsWith(".vue")) return;
1125
+ if (ignore.some((pattern) => filename.includes(pattern))) return;
1126
+ debounce();
1127
+ });
1128
+ watchers.push(watcher);
1129
+ } catch {
1130
+ console.warn(`[watch] Could not watch: ${root}`);
1131
+ }
1132
+ console.log(`[watch] Watching ${roots.size} root(s) for changes...`);
1133
+ process.on("SIGINT", () => {
1134
+ for (const w of watchers) w.close();
903
1135
  process.exit(0);
1136
+ });
1137
+ }
1138
+ //#endregion
1139
+ //#region src/cli.ts
1140
+ const main = defineCommand({
1141
+ meta: {
1142
+ name: "compmark",
1143
+ version: "0.3.0",
1144
+ description: "Auto-generate Markdown documentation from Vue 3 SFCs"
1145
+ },
1146
+ args: {
1147
+ out: {
1148
+ type: "string",
1149
+ description: "Output directory",
1150
+ default: "."
1151
+ },
1152
+ ignore: {
1153
+ type: "string",
1154
+ description: "Comma-separated ignore patterns"
1155
+ },
1156
+ join: {
1157
+ type: "boolean",
1158
+ description: "Combine output into a single file"
1159
+ },
1160
+ format: {
1161
+ type: "string",
1162
+ description: "Output format: md | json",
1163
+ default: "md"
1164
+ },
1165
+ watch: {
1166
+ type: "boolean",
1167
+ description: "Watch for changes and rebuild"
1168
+ },
1169
+ silent: {
1170
+ type: "boolean",
1171
+ description: "Suppress non-error output"
1172
+ }
1173
+ },
1174
+ async run({ args }) {
1175
+ const inputPaths = collectInputPaths();
1176
+ if (inputPaths.length === 0) {
1177
+ console.error("Error: No input files or directories specified");
1178
+ console.error("Usage: compmark <files/dirs/globs> [options]");
1179
+ process.exit(1);
1180
+ }
1181
+ const format = args.format;
1182
+ if (format !== "md" && format !== "json") {
1183
+ console.error(`Error: Unknown format "${format}". Use "md" or "json".`);
1184
+ process.exit(1);
1185
+ }
1186
+ const ignorePatterns = args.ignore ? args.ignore.split(",").map((s) => s.trim()).filter(Boolean) : [];
1187
+ const silent = args.silent ?? false;
1188
+ const joined = args.join ?? false;
1189
+ const outDir = args.out ?? ".";
1190
+ const rebuild = async () => {
1191
+ const filePaths = await discoverFiles(inputPaths, ignorePatterns);
1192
+ if (filePaths.length === 0) {
1193
+ if (!args.watch) {
1194
+ console.error("Error: No .vue files found");
1195
+ process.exit(1);
1196
+ }
1197
+ console.warn("Warning: No .vue files found");
1198
+ return null;
1199
+ }
1200
+ const summary = processFiles(filePaths, { silent });
1201
+ if (format === "json") writeJSON(summary.files, outDir, joined, silent);
1202
+ else if (joined) writeJoinedMarkdown(summary.files, outDir, silent);
1203
+ else writeIndividualMarkdown(summary.files, outDir, silent);
1204
+ if (!silent) console.log(`✓ ${summary.documented} components documented, ${summary.skipped} skipped, ${summary.errors} errors`);
1205
+ return summary;
1206
+ };
1207
+ const summary = await rebuild();
1208
+ if (args.watch) startWatcher(inputPaths, ignorePatterns, () => {
1209
+ rebuild();
1210
+ });
1211
+ else if (summary && summary.errors > 0) process.exit(1);
1212
+ }
1213
+ });
1214
+ function collectInputPaths() {
1215
+ const argv = process.argv.slice(2);
1216
+ const paths = [];
1217
+ const flagsWithValue = new Set([
1218
+ "--out",
1219
+ "--ignore",
1220
+ "--format"
1221
+ ]);
1222
+ let i = 0;
1223
+ while (i < argv.length) {
1224
+ const arg = argv[i];
1225
+ if (arg === "--") {
1226
+ paths.push(...argv.slice(i + 1));
1227
+ break;
1228
+ }
1229
+ if (flagsWithValue.has(arg)) i += 2;
1230
+ else if (arg.startsWith("--") && arg.includes("=")) i++;
1231
+ else if (arg.startsWith("--")) i++;
1232
+ else {
1233
+ paths.push(arg);
1234
+ i++;
1235
+ }
904
1236
  }
905
- const md = generateMarkdown(doc);
906
- const outFile = `${doc.name}.md`;
907
- writeFileSync(join(process.cwd(), outFile), md, "utf-8");
908
- console.log(`Created ${outFile}`);
909
- } catch (err) {
910
- const name = abs.split("/").pop() ?? filePath;
911
- const reason = err instanceof Error ? err.message : String(err);
912
- console.error(`Error: Could not parse ${name}: ${reason}`);
913
- process.exit(1);
1237
+ return paths;
914
1238
  }
1239
+ runMain(main);
915
1240
  //#endregion
916
1241
  export {};
package/dist/index.d.mts CHANGED
@@ -38,20 +38,49 @@ interface ComponentDoc {
38
38
  name: string;
39
39
  description?: string;
40
40
  internal?: boolean;
41
+ scriptSetup?: boolean;
41
42
  props: PropDoc[];
42
43
  emits: EmitDoc[];
43
44
  slots?: SlotDoc[];
44
45
  exposes?: ExposeDoc[];
45
46
  composables?: ComposableDoc[];
46
47
  }
48
+ type OutputFormat = "md" | "json";
49
+ interface RunSummary {
50
+ documented: number;
51
+ skipped: number;
52
+ errors: number;
53
+ files: Array<{
54
+ path: string;
55
+ doc: ComponentDoc;
56
+ }>;
57
+ errorDetails: Array<{
58
+ path: string;
59
+ error: string;
60
+ }>;
61
+ }
47
62
  //#endregion
48
63
  //#region src/parser.d.ts
49
64
  declare function parseSFC(source: string, filename: string, sfcDir?: string): ComponentDoc;
50
65
  //#endregion
51
66
  //#region src/markdown.d.ts
52
67
  declare function generateMarkdown(doc: ComponentDoc): string;
68
+ declare function adjustHeadingLevel(md: string, increment: number): string;
69
+ //#endregion
70
+ //#region src/discovery.d.ts
71
+ declare function discoverFiles(inputs: string[], ignore?: string[]): Promise<string[]>;
72
+ //#endregion
73
+ //#region src/runner.d.ts
74
+ declare function processFiles(filePaths: string[], options: {
75
+ silent?: boolean;
76
+ }): RunSummary;
77
+ //#endregion
78
+ //#region src/type-resolver.d.ts
79
+ declare function resolveImportedPropsType(typeName: string, importMap: Map<string, string>, sfcDir: string): {
80
+ members: Array<any>;
81
+ } | null;
53
82
  //#endregion
54
83
  //#region src/index.d.ts
55
84
  declare function parseComponent(filePath: string): ComponentDoc;
56
85
  //#endregion
57
- export { type ComponentDoc, type ComposableDoc, type ComposableVariable, type EmitDoc, type ExposeDoc, type PropDoc, type SlotDoc, generateMarkdown, parseComponent, parseSFC };
86
+ export { type ComponentDoc, type ComposableDoc, type ComposableVariable, type EmitDoc, type ExposeDoc, type OutputFormat, type PropDoc, type RunSummary, type SlotDoc, adjustHeadingLevel, discoverFiles, generateMarkdown, parseComponent, parseSFC, processFiles, resolveImportedPropsType };
package/dist/index.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync, statSync } from "node:fs";
2
2
  import { dirname, join, resolve } from "node:path";
3
3
  import { babelParse, compileScript, parse } from "@vue/compiler-sfc";
4
+ import { glob } from "tinyglobby";
4
5
  //#region src/resolver.ts
5
6
  function resolveImportPath(importSource, sfcDir) {
6
7
  try {
@@ -27,7 +28,7 @@ function resolveImportPath(importSource, sfcDir) {
27
28
  }
28
29
  }
29
30
  function tryResolveFile(basePath) {
30
- if (existsSync(basePath) && !isDirectory(basePath)) return basePath;
31
+ if (existsSync(basePath) && !isDirectory$1(basePath)) return basePath;
31
32
  for (const ext of [".ts", ".js"]) {
32
33
  const candidate = basePath + ext;
33
34
  if (existsSync(candidate)) return candidate;
@@ -38,7 +39,7 @@ function tryResolveFile(basePath) {
38
39
  }
39
40
  return null;
40
41
  }
41
- function isDirectory(filePath) {
42
+ function isDirectory$1(filePath) {
42
43
  try {
43
44
  return statSync(filePath).isDirectory();
44
45
  } catch {
@@ -264,6 +265,56 @@ function resolveTypeAnnotation(node) {
264
265
  }
265
266
  }
266
267
  //#endregion
268
+ //#region src/type-resolver.ts
269
+ function resolveImportedPropsType(typeName, importMap, sfcDir) {
270
+ const source = importMap.get(typeName);
271
+ if (!source) return null;
272
+ const resolvedPath = resolveImportPath(source, sfcDir);
273
+ if (!resolvedPath) return null;
274
+ try {
275
+ return findExportedType(babelParse(readFileSync(resolvedPath, "utf-8"), {
276
+ plugins: ["typescript"],
277
+ sourceType: "module"
278
+ }).program.body, typeName, 0);
279
+ } catch {
280
+ return null;
281
+ }
282
+ }
283
+ function findExportedType(stmts, typeName, depth) {
284
+ if (depth > 5) return null;
285
+ for (const stmt of stmts) {
286
+ if (stmt.type === "ExportNamedDeclaration" && stmt.declaration?.type === "TSInterfaceDeclaration" && stmt.declaration.id.name === typeName) return resolveInterfaceMembers(stmt.declaration, stmts, depth);
287
+ if (stmt.type === "ExportNamedDeclaration" && stmt.declaration?.type === "TSTypeAliasDeclaration" && stmt.declaration.id.name === typeName && stmt.declaration.typeAnnotation.type === "TSTypeLiteral") return { members: [...stmt.declaration.typeAnnotation.members] };
288
+ }
289
+ if (hasNamedExport(stmts, typeName)) return findTypeInFile(stmts, typeName, depth);
290
+ return null;
291
+ }
292
+ function findTypeInFile(stmts, typeName, depth) {
293
+ if (depth > 5) return null;
294
+ for (const stmt of stmts) {
295
+ if (stmt.type === "ExportNamedDeclaration" && stmt.declaration?.type === "TSInterfaceDeclaration" && stmt.declaration.id.name === typeName) return resolveInterfaceMembers(stmt.declaration, stmts, depth);
296
+ if (stmt.type === "ExportNamedDeclaration" && stmt.declaration?.type === "TSTypeAliasDeclaration" && stmt.declaration.id.name === typeName && stmt.declaration.typeAnnotation.type === "TSTypeLiteral") return { members: [...stmt.declaration.typeAnnotation.members] };
297
+ if (stmt.type === "TSInterfaceDeclaration" && stmt.id.name === typeName) return resolveInterfaceMembers(stmt, stmts, depth);
298
+ if (stmt.type === "TSTypeAliasDeclaration" && stmt.id.name === typeName && stmt.typeAnnotation.type === "TSTypeLiteral") return { members: [...stmt.typeAnnotation.members] };
299
+ }
300
+ return null;
301
+ }
302
+ function resolveInterfaceMembers(decl, stmts, depth) {
303
+ const members = [];
304
+ if (decl.extends) for (const ext of decl.extends) {
305
+ const parentName = ext.expression?.type === "Identifier" ? ext.expression.name : null;
306
+ if (parentName) {
307
+ const parent = findTypeInFile(stmts, parentName, depth + 1);
308
+ if (parent) members.push(...parent.members);
309
+ }
310
+ }
311
+ members.push(...decl.body.body);
312
+ return { members };
313
+ }
314
+ function hasNamedExport(stmts, name) {
315
+ return stmts.some((s) => s.type === "ExportNamedDeclaration" && !s.declaration && s.specifiers.some((spec) => spec.type === "ExportSpecifier" && (spec.local.type === "Identifier" && spec.local.name === name || spec.exported.type === "Identifier" && spec.exported.name === name)));
316
+ }
317
+ //#endregion
267
318
  //#region src/parser.ts
268
319
  function parseJSDocTags(comments) {
269
320
  const result = { description: "" };
@@ -290,29 +341,55 @@ function parseSFC(source, filename, sfcDir) {
290
341
  props: [],
291
342
  emits: []
292
343
  };
293
- const { descriptor } = parse(source, { filename });
344
+ const fullPath = sfcDir ? `${sfcDir}/${filename}` : filename;
345
+ const { descriptor } = parse(source, { filename: fullPath });
346
+ doc.scriptSetup = !!descriptor.scriptSetup;
294
347
  if (descriptor.template?.ast) {
295
348
  const templateSlots = extractTemplateSlots(descriptor.template.ast);
296
349
  if (templateSlots.length > 0) doc.slots = templateSlots;
297
350
  }
298
351
  if (!descriptor.scriptSetup && !descriptor.script) return doc;
299
- const compiled = compileScript(descriptor, { id: filename });
352
+ let compiled;
353
+ try {
354
+ compiled = compileScript(descriptor, {
355
+ id: fullPath,
356
+ fs: {
357
+ fileExists: (file) => existsSync(file),
358
+ readFile: (file) => {
359
+ try {
360
+ return readFileSync(file, "utf-8");
361
+ } catch {
362
+ return;
363
+ }
364
+ }
365
+ }
366
+ });
367
+ } catch {
368
+ return doc;
369
+ }
300
370
  const componentJSDoc = extractComponentJSDoc(compiled.scriptSetupAst ?? compiled.scriptAst ?? []);
301
371
  doc.description = componentJSDoc.description;
302
372
  doc.internal = componentJSDoc.internal;
303
373
  const setupAst = compiled.scriptSetupAst;
304
374
  if (setupAst) {
305
375
  const scriptSource = descriptor.scriptSetup?.content ?? compiled.content;
376
+ const importMap = buildImportMap(setupAst);
306
377
  for (const stmt of setupAst) {
307
378
  const calls = extractDefineCalls(stmt);
308
379
  for (const { callee, args, leadingComments, typeParams, defaultsArg } of calls) if (callee === "defineProps" && args[0]?.type === "ObjectExpression") doc.props = extractProps(args[0], scriptSource);
309
380
  else if (callee === "defineProps" && typeParams?.params[0]?.type === "TSTypeLiteral") doc.props = extractTypeProps(typeParams.params[0], defaultsArg, scriptSource);
310
- else if (callee === "defineEmits" && args[0]?.type === "ArrayExpression") doc.emits = extractEmits(args[0], leadingComments);
381
+ else if (callee === "defineProps" && typeParams?.params[0]?.type === "TSTypeReference") {
382
+ const typeName = typeParams.params[0].typeName?.name;
383
+ if (typeName && sfcDir) {
384
+ const resolved = resolveImportedPropsType(typeName, importMap, sfcDir);
385
+ if (resolved) doc.props = extractTypeProps(resolved, defaultsArg, scriptSource);
386
+ }
387
+ } else if (callee === "defineEmits" && args[0]?.type === "ArrayExpression") doc.emits = extractEmits(args[0], leadingComments);
311
388
  else if (callee === "defineEmits" && typeParams?.params[0]?.type === "TSTypeLiteral") doc.emits = extractTypeEmits(typeParams.params[0]);
312
389
  else if (callee === "defineSlots" && typeParams?.params[0]?.type === "TSTypeLiteral") doc.slots = extractTypeSlots(typeParams.params[0]);
313
390
  else if (callee === "defineExpose" && args[0]?.type === "ObjectExpression") doc.exposes = extractExposes(args[0], scriptSource);
314
391
  }
315
- doc.composables = extractComposables(setupAst, buildImportMap(setupAst), sfcDir);
392
+ doc.composables = extractComposables(setupAst, importMap, sfcDir);
316
393
  }
317
394
  const scriptAst = compiled.scriptAst;
318
395
  if (scriptAst && doc.props.length === 0 && doc.emits.length === 0) {
@@ -784,6 +861,7 @@ function escHtml(value) {
784
861
  function generateMarkdown(doc) {
785
862
  const sections = [`# ${doc.name}`];
786
863
  if (doc.description) sections.push("", doc.description);
864
+ if (doc.scriptSetup) sections.push("", "**Note:** Uses `<script setup>` syntax.");
787
865
  const hasProps = doc.props.length > 0;
788
866
  const hasEmits = doc.emits.length > 0;
789
867
  const hasSlots = (doc.slots?.length ?? 0) > 0;
@@ -872,6 +950,87 @@ function generateMarkdown(doc) {
872
950
  }
873
951
  return sections.join("\n") + "\n";
874
952
  }
953
+ function adjustHeadingLevel(md, increment) {
954
+ return md.replace(/^(#{1,6})\s/gm, (_, hashes) => {
955
+ const newLevel = Math.min(hashes.length + increment, 6);
956
+ return "#".repeat(newLevel) + " ";
957
+ });
958
+ }
959
+ //#endregion
960
+ //#region src/discovery.ts
961
+ async function discoverFiles(inputs, ignore) {
962
+ const baseIgnore = ["**/node_modules/**"];
963
+ const userIgnore = (ignore ?? []).map(normalizeIgnorePattern);
964
+ const allIgnore = [...baseIgnore, ...userIgnore];
965
+ const found = /* @__PURE__ */ new Set();
966
+ for (const input of inputs) {
967
+ const resolved = resolve(input);
968
+ if (input.endsWith(".vue") && existsSync(resolved)) found.add(resolved);
969
+ else if (isDirectory(resolved)) {
970
+ const files = await glob("**/*.vue", {
971
+ cwd: resolved,
972
+ absolute: true,
973
+ ignore: allIgnore
974
+ });
975
+ for (const f of files) found.add(f);
976
+ } else {
977
+ const files = await glob(input, {
978
+ absolute: true,
979
+ ignore: allIgnore
980
+ });
981
+ for (const f of files) found.add(f);
982
+ }
983
+ }
984
+ return [...found].sort();
985
+ }
986
+ function isDirectory(path) {
987
+ try {
988
+ return statSync(path).isDirectory();
989
+ } catch {
990
+ return false;
991
+ }
992
+ }
993
+ function normalizeIgnorePattern(pattern) {
994
+ if (pattern.includes("*") || pattern.includes("/")) return pattern;
995
+ return `**/${pattern}/**`;
996
+ }
997
+ //#endregion
998
+ //#region src/runner.ts
999
+ function processFiles(filePaths, options) {
1000
+ const summary = {
1001
+ documented: 0,
1002
+ skipped: 0,
1003
+ errors: 0,
1004
+ files: [],
1005
+ errorDetails: []
1006
+ };
1007
+ for (const filePath of filePaths) try {
1008
+ const doc = parseComponent(filePath);
1009
+ if (doc.internal) {
1010
+ summary.skipped++;
1011
+ if (!options.silent) {
1012
+ const name = filePath.split("/").pop() ?? filePath;
1013
+ console.log(` Skipped ${name} (marked @internal)`);
1014
+ }
1015
+ continue;
1016
+ }
1017
+ summary.documented++;
1018
+ summary.files.push({
1019
+ path: filePath,
1020
+ doc
1021
+ });
1022
+ } catch (err) {
1023
+ summary.errors++;
1024
+ const name = filePath.split("/").pop() ?? filePath;
1025
+ const message = err instanceof Error ? err.message : String(err);
1026
+ summary.errorDetails.push({
1027
+ path: filePath,
1028
+ error: message
1029
+ });
1030
+ if (!options.silent) console.warn(` Warning: Could not parse ${name}: ${message}`);
1031
+ }
1032
+ return summary;
1033
+ }
875
1034
  //#endregion
876
1035
  //#region src/index.ts
877
1036
  function parseComponent(filePath) {
@@ -879,4 +1038,4 @@ function parseComponent(filePath) {
879
1038
  return parseSFC(readFileSync(abs, "utf-8"), abs.split("/").pop() ?? "Unknown.vue", abs.substring(0, abs.lastIndexOf("/")));
880
1039
  }
881
1040
  //#endregion
882
- export { generateMarkdown, parseComponent, parseSFC };
1041
+ export { adjustHeadingLevel, discoverFiles, generateMarkdown, parseComponent, parseSFC, processFiles, resolveImportedPropsType };
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "compmark-vue",
3
- "version": "0.2.6",
3
+ "version": "0.3.1",
4
4
  "description": "Auto-generate Markdown documentation from Vue 3 SFCs",
5
5
  "license": "MIT",
6
6
  "repository": "noopurphalak/compmark-vue",
7
7
  "bin": {
8
- "compmark": "./dist/cli.mjs"
8
+ "compmark": "./dist/cli.mjs",
9
+ "compmark-vue": "./dist/cli.mjs"
9
10
  },
10
11
  "files": [
11
12
  "dist"
@@ -28,7 +29,9 @@
28
29
  "prepare": "husky"
29
30
  },
30
31
  "dependencies": {
31
- "@vue/compiler-sfc": "^3.5.0"
32
+ "@vue/compiler-sfc": "^3.5.0",
33
+ "citty": "^0.2.1",
34
+ "tinyglobby": "^0.2.15"
32
35
  },
33
36
  "devDependencies": {
34
37
  "@babel/types": "latest",
@@ -51,5 +54,8 @@
51
54
  "oxfmt"
52
55
  ]
53
56
  },
57
+ "engines": {
58
+ "node": ">=20"
59
+ },
54
60
  "packageManager": "pnpm@10.29.3"
55
61
  }