Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions docs/2.generators/ui-code-tree.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# ui-code-tree

The `ui-code-tree` generator creates a [Nuxt UI CodeTree](https://ui.nuxt.com/components/code-tree) component from a directory. It reads all files in the directory and generates MDC syntax that renders as an interactive file browser with syntax-highlighted code.

## Example

### Input

<!-- automd:ui-code-tree src="./src" default="index.ts" -->
<!-- /automd -->

### Output

<!-- automd:ui-code-tree src="./src" default="index.ts" -->

::code-tree{defaultValue="index.ts"}

```ts [index.ts]
export { automd } from "./automd.ts";
```

```ts [config.ts]
export interface Config {
input: string[];
}
```

::

<!-- /automd -->

## Arguments

::field-group

::field{name="src" type="string"}
Relative path to the directory. Defaults to `.`.
::

::field{name="default" type="string"}
The file path to select by default in the code tree (e.g., `default="src/index.ts"`).
::

::field{name="ignore" type="string"}
Comma-separated list of additional patterns to ignore (e.g., `ignore="README.md,*.test.ts"`).
::

::field{name="maxDepth" type="number"}
Maximum depth to traverse. Use `maxDepth=1` to show only the top level.
::

::field{name="expandAll" type="boolean"}
Expand all directories by default in the rendered code tree.
::

::

## Default Ignores

The following are ignored by default:

- `node_modules`
- `.git`
- `.DS_Store`
- `.output`
- `dist`
- `coverage`
- `.cache`
- `pnpm-lock.yaml`
- `package-lock.json`
- `yarn.lock`

Additionally, patterns from `.gitignore` in the target directory are automatically respected.

## Supported Languages

File extensions are automatically mapped to syntax highlighting languages:

| Extension | Language |
|-----------|----------|
| `.ts`, `.tsx` | TypeScript |
| `.js`, `.jsx`, `.mjs`, `.cjs` | JavaScript |
| `.vue` | Vue |
| `.json` | JSON |
| `.html` | HTML |
| `.css`, `.scss` | CSS |
| `.md` | Markdown |
| `.yaml`, `.yml` | YAML |
| `.toml` | TOML |
| `.sh`, `.bash`, `.zsh` | Bash |
2 changes: 2 additions & 0 deletions src/generators/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { withAutomd } from "./with-automd.ts";
import { file } from "./file.ts";
import { contributors } from "./contributors.ts";
import { dirTree } from "./dir-tree.ts";
import { uiCodeTree } from "./ui-code-tree.ts";

export default {
jsdocs,
Expand All @@ -21,4 +22,5 @@ export default {
"with-automd": withAutomd,
contributors,
"dir-tree": dirTree,
"ui-code-tree": uiCodeTree,
} as Record<string, Generator>;
219 changes: 219 additions & 0 deletions src/generators/ui-code-tree.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import { readdir, stat, readFile } from "node:fs/promises";
import { join, extname, relative, basename } from "pathe";
import { defineGenerator } from "../generator.ts";
import { resolvePath } from "../_utils.ts";

interface FileEntry {
path: string;
relativePath: string;
content: string;
language: string;
}

const DEFAULT_IGNORE = [
"node_modules",
".git",
".DS_Store",
".nuxt",
".output",
".nitro",
"dist",
"coverage",
".cache",
".turbo",
"pnpm-lock.yaml",
"package-lock.json",
"yarn.lock",
];

const EXTENSION_LANGUAGE_MAP: Record<string, string> = {
".ts": "ts",
".tsx": "tsx",
".js": "js",
".jsx": "jsx",
".mjs": "js",
".cjs": "js",
".vue": "vue",
".json": "json",
".html": "html",
".css": "css",
".scss": "scss",
".md": "md",
".yaml": "yaml",
".yml": "yaml",
".toml": "toml",
".sh": "bash",
".bash": "bash",
".zsh": "bash",
};

async function parseGitignore(dir: string): Promise<string[]> {
try {
const gitignorePath = join(dir, ".gitignore");
const content = await readFile(gitignorePath, "utf8");
return content
.split("\n")
.map((line) => line.trim())
.filter((line) => line && !line.startsWith("#"));
} catch {
return [];
}
}

function shouldIgnore(name: string, ignorePatterns: string[], defaultIgnore: string[]): boolean {
const allPatterns = [...defaultIgnore, ...ignorePatterns];
for (const pattern of allPatterns) {
const cleanPattern = pattern.replace(/^\//, "").replace(/\/$/, "");
if (name === cleanPattern) {
return true;
}
if (pattern.startsWith("*") && name.endsWith(pattern.slice(1))) {
return true;
}
if (pattern.endsWith("*") && name.startsWith(pattern.slice(0, -1))) {
return true;
}
}
return false;
}

function getLanguage(filePath: string): string {
const ext = extname(filePath).toLowerCase();
return EXTENSION_LANGUAGE_MAP[ext] || "text";
}

async function collectFiles(
dir: string,
baseDir: string,
ignorePatterns: string[],
maxDepth: number,
currentDepth: number = 0,
): Promise<FileEntry[]> {
if (maxDepth > 0 && currentDepth >= maxDepth) {
return [];
}

const entries = await readdir(dir);
const files: FileEntry[] = [];

for (const entry of entries) {
if (shouldIgnore(entry, ignorePatterns, DEFAULT_IGNORE)) {
continue;
}

const fullPath = join(dir, entry);
const stats = await stat(fullPath);

if (stats.isDirectory()) {
const nestedFiles = await collectFiles(
fullPath,
baseDir,
ignorePatterns,
maxDepth,
currentDepth + 1,
);
files.push(...nestedFiles);
} else {
try {
const content = await readFile(fullPath, "utf8");
const relativePath = relative(baseDir, fullPath);
files.push({
path: fullPath,
relativePath,
content: content.trim(),
language: getLanguage(fullPath),
});
} catch {
// Skip binary or unreadable files
}
}
}

return files;
}

function sortFiles(files: FileEntry[]): FileEntry[] {
return files.sort((a, b) => {
const aParts = a.relativePath.split("/");
const bParts = b.relativePath.split("/");

// Sort by depth first (shallower files first)
if (aParts.length !== bParts.length) {
return aParts.length - bParts.length;
}

// Then alphabetically
return a.relativePath.localeCompare(b.relativePath);
});
}

function generateCodeTree(
files: FileEntry[],
options: { defaultValue?: string; expandAll?: boolean } = {},
): string {
const sortedFiles = sortFiles(files);
const codeBlocks: string[] = [];

for (const file of sortedFiles) {
const lang = file.language;
const filename = file.relativePath;

// Use 4 backticks for markdown files to avoid conflicts
const fence = lang === "md" ? "````" : "```";
codeBlocks.push(`${fence}${lang} [${filename}]`);
codeBlocks.push(file.content);
codeBlocks.push(fence);
codeBlocks.push("");
}

const attrs: string[] = [];
if (options.defaultValue) {
attrs.push(`defaultValue="${options.defaultValue}"`);
}
if (options.expandAll) {
attrs.push(`expandAll`);
}
const propsStr = attrs.length > 0 ? `{${attrs.join(" ")}}` : "";
const contents = `::code-tree${propsStr}\n\n${codeBlocks.join("\n").trim()}\n\n::`;

return contents;
}

export const uiCodeTree = defineGenerator({
name: "ui-code-tree",
async generate({ args, config, url }) {
const srcPath = args.src || ".";
const fullPath = resolvePath(srcPath, { url, dir: config.dir });

const stats = await stat(fullPath);
if (!stats.isDirectory()) {
throw new Error(`Path "${srcPath}" is not a directory`);
}

const userIgnore: string[] = args.ignore
? String(args.ignore)
.split(",")
.map((s: string) => s.trim())
: [];

const gitignorePatterns = await parseGitignore(fullPath);
const ignorePatterns = [...gitignorePatterns, ...userIgnore];

const maxDepth = args.maxDepth ? Number(args.maxDepth) : 0;
const defaultValue = args.defaultValue || args.default;
const expandAll = args.expandAll !== undefined && args.expandAll !== "false";

const files = await collectFiles(fullPath, fullPath, ignorePatterns, maxDepth);

if (files.length === 0) {
return {
contents: "<!-- No files found -->",
issues: ["No files found in the specified directory"],
};
}

const contents = generateCodeTree(files, { defaultValue, expandAll });

return { contents };
},
});