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
224 changes: 224 additions & 0 deletions automd.config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,190 @@
import type { Config } from "automd";
import { readdir, stat, readFile } from "node:fs/promises";
import { join, extname, relative } from "pathe";

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;
}

function resolvePath(srcPath: string, options: { url?: string; dir?: string }): string {
if (srcPath.startsWith("/")) {
return srcPath;
}
const base = options.url ? new URL(".", options.url).pathname : options.dir || process.cwd();
return join(base, srcPath);
}

export default {
input: ["README.md", "docs/**/*.md"],
Expand All @@ -22,5 +208,43 @@ export default {
};
},
},
"ui-code-tree": {
name: "ui-code-tree",
async generate({ args, config, url }: { args: Record<string, unknown>; config: { dir?: string }; url?: string }) {
const srcPath = (args.src as string) || ".";
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) as string | undefined;
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 };
},
},
},
} satisfies Config;
20 changes: 20 additions & 0 deletions docs/.docs/content.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { defineContentConfig, defineCollection, z } from '@nuxt/content'
import { resolve } from 'pathe'

export default defineContentConfig({
collections: {
examples: defineCollection({
type: 'page',
source: {
cwd: resolve(__dirname, '../../examples'),
include: '**/README.md',
prefix: '/examples',
exclude: ['**/.**/**', '**/node_modules/**', '**/dist/**', '**/.docs/**'],
},
schema: z.object({
category: z.string().optional(),
icon: z.string().optional(),
}),
}),
},
})
85 changes: 85 additions & 0 deletions docs/.docs/layouts/examples.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<script setup lang="ts">
import type { ContentNavigationItem } from '@nuxt/content'
import { categoryOrder } from '~/utils/examples'

// Fetch all examples and group by category
const { data: examples } = await useAsyncData('examples-nav', () =>
queryCollection('examples')
.select('title', 'description', 'category', 'path')
.all(),
)

// Group examples by category
const groupedExamples = computed(() => {
if (!examples.value) return []

const groups: Record<string, ContentNavigationItem[]> = {}

for (const example of examples.value) {
const category = example.category || 'Other'
if (!groups[category]) {
groups[category] = []
}
groups[category].push({
title: example.title,
path: example.path.replace(/\/readme$/i, ''),
})
}

// Convert to navigation items with children, sorted by categoryOrder
const sortedEntries = Object.entries(groups).sort(([a], [b]) => {
const aIndex = categoryOrder.indexOf(a.toLowerCase())
const bIndex = categoryOrder.indexOf(b.toLowerCase())
if (aIndex === -1 && bIndex === -1) return a.localeCompare(b)
if (aIndex === -1) return 1
if (bIndex === -1) return -1
return aIndex - bIndex
})

return sortedEntries.map(([category, items]) => ({
title: category.split(' ').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' '),
path: '',
children: items,
}))
})

// Flat list for navigation (no groups)
const flatExamples = computed(() => {
if (!examples.value) return []
return examples.value.map((example) => ({
title: example.title,
path: example.path.replace(/\/readme$/i, ''),
}))
})
</script>

<template>
<UContainer>
<UPage :ui="{ left: 'lg:col-span-2 pr-2 border-r border-default' }">
<template #left>
<UPageAside>
<UPageAnchors
:links="[
{ label: 'Docs', icon: 'i-lucide-book-open', to: '/docs' },
{ label: 'Deploy', icon: 'ri:upload-cloud-2-line', to: '/deploy' },
{ label: 'Config', icon: 'ri:settings-3-line', to: '/config' },
{ label: 'Examples', icon: 'i-lucide-folder-code', to: '/examples', active: true },
]"
/>
<USeparator type="dashed" class="py-6" />
<UContentNavigation
v-if="groupedExamples.length"
:navigation="groupedExamples"
:collapsible="false"
/>
<UContentNavigation
v-else-if="flatExamples.length"
:navigation="flatExamples"
:collapsible="false"
/>
</UPageAside>
</template>
<slot />
</UPage>
</UContainer>
</template>
Loading