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:
@@ -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
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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
@@ -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,7 +1,4 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@import "tailwindcss/index.css";
|
||||
:root {
|
||||
--bg-page: #0a0a0f;
|
||||
--bg-surface: #111118;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user