feat: enhanced glob patterns, symlink handling, and Tailwind CSS compilation

- Added `recursive` option to GlobOptions to control whether simple glob patterns match recursively by default (true).
- Changed glob pattern behavior to always apply recursive matching for simple patterns instead of requiring explicit `**/` prefix.
- Improved symlink handling in glob file type filters to resolve symlink targets and match based on their actual type (file/dir).
- Enhanced skill discovery in coding-agent to use dual-pass glob approach with deduplication and improved extension module detection.
- Implemented dynamic Tailwind CSS compilation in stats package by extracting actual class names from source files instead of building with empty candidates.
- Refactored Tailwind stylesheet imports to use centralized `tailwindcss/index.css` import with custom stylesheet loader callback.
This commit is contained in:
can1357
2026-02-15 14:15:04 +01:00
parent 5f2f26a901
commit 79cfe855c9
8 changed files with 228 additions and 65 deletions
+53 -13
View File
@@ -34,9 +34,12 @@ pub struct GlobOptions<'env> {
pub pattern: String,
/// Directory to search.
pub path: String,
/// Filter by file type: "file", "dir", or "symlink".
/// Filter by file type: "file", "dir", or "symlink". Symlinks are
/// matched for file/dir filters based on their target type.
#[napi(js_name = "fileType")]
pub file_type: Option<FileType>,
/// Match simple patterns recursively by default (`*.ts` -> recursive).
pub recursive: Option<bool>,
/// Include hidden files (default: false).
pub hidden: Option<bool>,
/// Maximum number of results to return.
@@ -69,22 +72,22 @@ pub struct GlobResult {
pub total_matches: u32,
}
fn build_glob_pattern(glob: &str) -> String {
fn build_glob_pattern(glob: &str, recursive: bool) -> String {
let normalized = if cfg!(windows) && glob.contains('\\') {
std::borrow::Cow::Owned(glob.replace('\\', "/"))
} else {
std::borrow::Cow::Borrowed(glob)
};
if normalized.contains('/') || normalized.starts_with("**") {
if !recursive || normalized.contains('/') || normalized.starts_with("**") {
normalized.into_owned()
} else {
format!("**/{normalized}")
}
}
fn compile_glob(glob: &str) -> Result<GlobSet> {
fn compile_glob(glob: &str, recursive: bool) -> Result<GlobSet> {
let mut builder = GlobSetBuilder::new();
let pattern = build_glob_pattern(glob);
let pattern = build_glob_pattern(glob, recursive);
let glob = Glob::new(&pattern)
.map_err(|err| Error::from_reason(format!("Invalid glob pattern: {err}")))?;
builder.add(glob);
@@ -97,6 +100,7 @@ fn compile_glob(glob: &str) -> Result<GlobSet> {
struct GlobConfig {
root: std::path::PathBuf,
pattern: String,
recursive: bool,
include_hidden: bool,
file_type_filter: Option<FileType>,
max_results: usize,
@@ -106,6 +110,41 @@ struct GlobConfig {
use_cache: bool,
}
fn resolve_symlink_target_type(root: &Path, relative_path: &str) -> Option<FileType> {
let target_path = root.join(relative_path);
let metadata = std::fs::metadata(target_path).ok()?;
if metadata.is_dir() {
Some(FileType::Dir)
} else if metadata.is_file() {
Some(FileType::File)
} else {
None
}
}
fn apply_file_type_filter(entry: &GlobMatch, config: &GlobConfig) -> Option<FileType> {
let Some(filter) = config.file_type_filter else {
return Some(entry.file_type);
};
if entry.file_type == filter {
return Some(entry.file_type);
}
if entry.file_type != FileType::Symlink {
return None;
}
match filter {
FileType::File | FileType::Dir => {
let resolved = resolve_symlink_target_type(&config.root, &entry.path)?;
if resolved == filter {
Some(resolved)
} else {
None
}
},
FileType::Symlink => None,
}
}
/// Filter and collect matching entries from a pre-scanned list.
fn filter_entries(
entries: &[GlobMatch],
@@ -128,17 +167,16 @@ fn filter_entries(
if !glob_set.is_match(&entry.path) {
continue;
}
if config
.file_type_filter
.is_some_and(|filter| filter != entry.file_type)
{
let Some(effective_file_type) = apply_file_type_filter(entry, config) else {
continue;
}
};
let mut matched_entry = entry.clone();
matched_entry.file_type = effective_file_type;
if let Some(callback) = on_match {
callback.call(Ok(entry.clone()), ThreadsafeFunctionCallMode::NonBlocking);
callback.call(Ok(matched_entry.clone()), ThreadsafeFunctionCallMode::NonBlocking);
}
matches.push(entry.clone());
matches.push(matched_entry);
// Only early-break when not sorting; mtime sort requires full candidate set.
if !config.sort_by_mtime && matches.len() >= config.max_results {
break;
@@ -154,7 +192,7 @@ fn run_glob(
on_match: Option<&ThreadsafeFunction<GlobMatch>>,
ct: task::CancelToken,
) -> Result<GlobResult> {
let glob_set = compile_glob(&config.pattern)?;
let glob_set = compile_glob(&config.pattern, config.recursive)?;
if config.max_results == 0 {
return Ok(GlobResult { matches: Vec::new(), total_matches: 0 });
}
@@ -225,6 +263,7 @@ pub fn glob(
pattern,
path,
file_type,
recursive,
hidden,
max_results,
gitignore,
@@ -247,6 +286,7 @@ pub fn glob(
root: fs_cache::resolve_search_path(&path)?,
include_hidden: hidden.unwrap_or(false),
file_type_filter: file_type,
recursive: recursive.unwrap_or(true),
max_results: max_results.map_or(usize::MAX, |value| value as usize),
use_gitignore: gitignore.unwrap_or(true),
mentions_node_modules: include_node_modules
+59 -37
View File
@@ -225,6 +225,20 @@ export function parseAgentFields(frontmatter: Record<string, unknown>): ParsedAg
return { name, description, tools, spawns, model, output, thinkingLevel };
}
async function globIf(
dir: string,
pattern: string,
fileType: FileType,
recursive: boolean = true,
): Promise<Array<{ path: string }>> {
try {
const result = await glob({ pattern, path: dir, gitignore: true, hidden: false, fileType, recursive });
return result.matches;
} catch {
return [];
}
}
export async function loadSkillsFromDir(
_ctx: LoadContext,
options: {
@@ -239,18 +253,19 @@ export async function loadSkillsFromDir(
const { dir, level, providerId, requireDescription = false } = options;
// Use native glob to find all SKILL.md files one level deep
// Pattern */SKILL.md matches <dir>/<subdir>/SKILL.md
let matches: Array<{ path: string }>;
try {
const result = await glob({
pattern: "*/SKILL.md",
path: dir,
gitignore: true,
hidden: false,
fileType: FileType.File,
});
matches = result.matches;
} catch {
// Directory doesn't exist or isn't readable
const discoveredMatches = new Set<string>();
for (const match of await globIf(dir, "*/SKILL.md", FileType.File)) {
discoveredMatches.add(match.path);
}
for (const match of await globIf(dir, "*", FileType.Dir, false)) {
const skillRelPath = `${match.path}/SKILL.md`;
const content = await readFile(path.join(dir, skillRelPath));
if (content !== null) {
discoveredMatches.add(skillRelPath);
}
}
const matches = [...discoveredMatches].map(path => ({ path }));
if (matches.length === 0) {
return { items, warnings };
}
@@ -456,48 +471,55 @@ async function readExtensionModuleManifest(
* Uses native glob for fast filesystem scanning with gitignore support.
*/
export async function discoverExtensionModulePaths(_ctx: LoadContext, dir: string): Promise<string[]> {
const discovered: string[] = [];
const discovered = new Set<string>();
// Find all candidate files in parallel using glob
const [directFiles, indexFiles, packageJsonFiles] = await Promise.all([
// 1. Direct *.ts or *.js files
glob({ pattern: "*.{ts,js}", path: dir, gitignore: true, hidden: false, fileType: FileType.File }),
globIf(dir, "*.{ts,js}", FileType.File, false),
// 2. Subdirectory index files
glob({ pattern: "*/index.{ts,js}", path: dir, gitignore: true, hidden: false, fileType: FileType.File }),
globIf(dir, "*/index.{ts,js}", FileType.File),
// 3. Subdirectory package.json files
glob({ pattern: "*/package.json", path: dir, gitignore: true, hidden: false, fileType: FileType.File }),
globIf(dir, "*/package.json", FileType.File),
]);
// Process direct files
for (const match of directFiles.matches) {
discovered.push(path.join(dir, match.path));
for (const match of directFiles) {
if (match.path.includes("/")) continue;
discovered.add(path.join(dir, match.path));
}
// Track which subdirectories have package.json
const subdirsWithPackageJson = new Set<string>();
for (const match of packageJsonFiles.matches) {
// Track which subdirectories have package.json manifests with declared extensions
const subdirsWithDeclaredExtensions = new Set<string>();
for (const match of packageJsonFiles) {
const subdir = path.dirname(match.path); // e.g., "my-extension"
subdirsWithPackageJson.add(subdir);
const packageJsonPath = path.join(dir, match.path);
const manifest = await readExtensionModuleManifest(_ctx, packageJsonPath);
if (manifest?.extensions && Array.isArray(manifest.extensions)) {
const subdirPath = path.join(dir, subdir);
for (const extPath of manifest.extensions) {
const resolvedExtPath = path.resolve(subdirPath, extPath);
const content = await readFile(resolvedExtPath);
if (content !== null) {
discovered.push(resolvedExtPath);
}
const declaredExtensions =
manifest?.extensions?.filter((extPath): extPath is string => typeof extPath === "string") ?? [];
if (declaredExtensions.length === 0) continue;
subdirsWithDeclaredExtensions.add(subdir);
const subdirPath = path.join(dir, subdir);
for (const extPath of declaredExtensions) {
const resolvedExtPath = path.resolve(subdirPath, extPath);
const content = await readFile(resolvedExtPath);
if (content !== null) {
discovered.add(resolvedExtPath);
}
}
}
// Process index files (skip if subdirectory has package.json)
for (const match of indexFiles.matches) {
const preferredIndexBySubdir = new Map<string, string>();
for (const match of indexFiles) {
if (match.path.split("/").length !== 2) continue;
const subdir = path.dirname(match.path);
if (subdirsWithPackageJson.has(subdir)) continue; // package.json takes precedence
discovered.push(path.join(dir, match.path));
if (subdirsWithDeclaredExtensions.has(subdir)) continue;
const existing = preferredIndexBySubdir.get(subdir);
if (!existing || (existing.endsWith("index.js") && match.path.endsWith("index.ts"))) {
preferredIndexBySubdir.set(subdir, match.path);
}
}
return discovered;
for (const preferredPath of preferredIndexBySubdir.values()) {
discovered.add(path.join(dir, preferredPath));
}
return [...discovered];
}
/**
+8
View File
@@ -1,6 +1,14 @@
# Changelog
## [Unreleased]
### Added
- Added `recursive` option to `GlobOptions` to control whether simple patterns match recursively (defaults to true)
### Changed
- Changed default glob pattern behavior to always use recursive matching for simple patterns instead of requiring explicit `**/` prefix
- Updated `fileType` filter documentation to clarify that symlinks match file/dir filters based on their target type
## [12.4.0] - 2026-02-14
### Added
+2 -5
View File
@@ -16,10 +16,6 @@ export { FileType } from "./types";
export async function glob(options: GlobOptions, onMatch?: (match: GlobMatch) => void): Promise<GlobResult> {
const searchPath = path.resolve(options.path);
const pattern = options.pattern || "*";
// Convert simple patterns to recursive globs if needed
const globPattern = pattern.includes("/") || pattern.startsWith("**") ? pattern : `**/${pattern}`;
// napi-rs ThreadsafeFunction passes (error, value) - skip callback on error
const cb = onMatch ? (err: Error | null, m: GlobMatch) => !err && onMatch(m) : undefined;
@@ -27,9 +23,10 @@ export async function glob(options: GlobOptions, onMatch?: (match: GlobMatch) =>
{
...options,
path: searchPath,
pattern: globPattern,
pattern,
hidden: options.hidden ?? false,
gitignore: options.gitignore ?? true,
recursive: options.recursive ?? true,
},
cb,
);
+4 -2
View File
@@ -19,8 +19,10 @@ export interface GlobOptions extends Cancellable {
pattern: string;
/** Directory to search. */
path: string;
/** Filter by file type: "file", "dir", or "symlink". */
/** Filter by file type: "file", "dir", or "symlink". Symlinks match file/dir filters when their target type matches. */
fileType?: FileType;
/** Match simple patterns recursively by default (example: *.ts -> recursive match). Set false to keep patterns relative to the search root only. */
recursive?: boolean;
/** Include hidden files (default: false). */
hidden?: boolean;
/** Maximum number of results to return. */
@@ -39,7 +41,7 @@ export interface GlobOptions extends Cancellable {
export interface GlobMatch {
/** Relative path from the search root. */
path: string;
/** Resolved filesystem type for the match. */
/** Resolved filesystem type for the match (for fileType=file/dir filters, symlink targets are reported as file/dir). */
fileType: FileType;
/** Modification time in milliseconds since epoch, if available. */
mtime?: number;
+54 -2
View File
@@ -2,19 +2,71 @@ import * as fs from "node:fs/promises";
import * as path from "node:path";
import { compile } from "tailwindcss";
/**
* Extract Tailwind class names from source files by scanning for className attributes.
*/
async function extractTailwindClasses(dir: string): Promise<Set<string>> {
const classes = new Set<string>();
const classPattern = /className\s*=\s*["'`]([^"'`]+)["'`]/g;
async function scanDir(currentDir: string): Promise<void> {
const entries = await fs.readdir(currentDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
await scanDir(fullPath);
} else if (entry.isFile() && /\.(tsx|ts|jsx|js)$/.test(entry.name)) {
const content = await Bun.file(fullPath).text();
const matches = content.matchAll(classPattern);
for (const match of matches) {
for (const cls of match[1].split(/\s+/)) {
if (cls) classes.add(cls);
}
}
}
}
}
await scanDir(dir);
return classes;
}
// Clean dist
await fs.rm("./dist/client", { recursive: true, force: true });
// Build Tailwind CSS
console.log("Building Tailwind CSS...");
const sourceCss = await Bun.file("./src/client/styles.css").text();
const candidates = await extractTailwindClasses("./src/client");
const baseDir = path.resolve("./src/client");
// Provide loadStylesheet for @import "tailwindcss" to work
const compiler = await compile(sourceCss, {
base: path.resolve("./src/client"),
base: baseDir,
loadStylesheet: async (id: string, base: string) => {
// Handle tailwindcss import
if (id === "tailwindcss/index.css" || id === "tailwindcss") {
const tailwindPath = require.resolve("tailwindcss/index.css", { paths: [base] });
return {
path: tailwindPath,
base: path.dirname(tailwindPath),
content: await Bun.file(tailwindPath).text(),
};
}
// Handle other imports relative to base
const resolved = path.resolve(base, id);
return {
path: resolved,
base: path.dirname(resolved),
content: await Bun.file(resolved).text(),
};
},
});
const tailwindOutput = compiler.build([]);
const tailwindOutput = compiler.build([...candidates]);
await Bun.write("./dist/client/styles.css", tailwindOutput);
// Build React app
console.log("Building React app...");
const result = await Bun.build({
entrypoints: ["./src/client/index.tsx"],
outdir: "./dist/client",
+1 -4
View File
@@ -1,7 +1,4 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "tailwindcss/index.css";
:root {
--bg-page: #0a0a0f;
--bg-surface: #111118;
+47 -2
View File
@@ -12,6 +12,33 @@ import {
} from "./aggregator";
import { EMBEDDED_CLIENT_ARCHIVE_TAR_GZ_BASE64 } from "./embedded-client.generated";
/**
* Extract Tailwind class names from source files by scanning for className attributes.
*/
async function extractTailwindClasses(dir: string): Promise<Set<string>> {
const classes = new Set<string>();
const classPattern = /className\s*=\s*["'`]([^"'`]+)["'`]/g;
async function scanDir(currentDir: string): Promise<void> {
const entries = await fs.readdir(currentDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
await scanDir(fullPath);
} else if (entry.isFile() && /\.(tsx|ts|jsx|js)$/.test(entry.name)) {
const content = await Bun.file(fullPath).text();
const matches = content.matchAll(classPattern);
for (const match of matches) {
for (const cls of match[1].split(/\s+/)) {
if (cls) classes.add(cls);
}
}
}
}
}
await scanDir(dir);
return classes;
}
const CLIENT_DIR = path.join(import.meta.dir, "client");
const STATIC_DIR = path.join(import.meta.dir, "..", "dist", "client");
const IS_BUN_COMPILED =
@@ -74,10 +101,28 @@ async function getCompiledClientDir(): Promise<string> {
async function buildTailwindCss(inputPath: string, outputPath: string): Promise<void> {
const sourceCss = await Bun.file(inputPath).text();
const clientDir = path.dirname(inputPath);
const candidates = await extractTailwindClasses(clientDir);
const compiler = await compile(sourceCss, {
base: path.dirname(inputPath),
base: clientDir,
loadStylesheet: async (id: string, base: string) => {
if (id === "tailwindcss/index.css" || id === "tailwindcss") {
const tailwindPath = require.resolve("tailwindcss/index.css", { paths: [base] });
return {
path: tailwindPath,
base: path.dirname(tailwindPath),
content: await Bun.file(tailwindPath).text(),
};
}
const resolved = path.resolve(base, id);
return {
path: resolved,
base: path.dirname(resolved),
content: await Bun.file(resolved).text(),
};
},
});
const result = compiler.build([]);
const result = compiler.build([...candidates]);
await Bun.write(outputPath, result);
}