diff --git a/packages/ono/example/barrels/blog.ts b/packages/ono/example/barrels/blog.ts new file mode 100644 index 0000000..f44dce6 --- /dev/null +++ b/packages/ono/example/barrels/blog.ts @@ -0,0 +1,20 @@ +// Auto-generated barrel file - DO NOT EDIT +// Generated by Ono SSG + +export type Meta = { + title: string; + date: string; + author: string; + tags?: string[]; +}; + +export { default as gettingStarted, meta as gettingStartedMeta } from './blog/getting-started.tsx'; +export { default as helloWorld, meta as helloWorldMeta } from './blog/hello-world.tsx'; + +export const entries = ['getting-started', 'hello-world'] as const; +export type EntryId = typeof entries[number]; + +export const posts = { + 'getting-started': { component: gettingStarted, meta: gettingStartedMeta }, + 'hello-world': { component: helloWorld, meta: helloWorldMeta } +}; diff --git a/packages/ono/example/barrels/blog/getting-started.tsx b/packages/ono/example/barrels/blog/getting-started.tsx new file mode 100644 index 0000000..305514f --- /dev/null +++ b/packages/ono/example/barrels/blog/getting-started.tsx @@ -0,0 +1,24 @@ +export const meta = { + title: "Getting Started with Ono", + date: "2025-01-03", + author: "hashrock", + tags: ["tutorial", "ono"], +}; + +export default function GettingStarted() { + return ( +
+

{meta.title}

+

{meta.date} by {meta.author}

+
+ {meta.tags.map((tag) => ( + {tag} + ))} +
+

Installation

+
npm install @hashrock/ono
+

Usage

+

Create a pages directory and add JSX files.

+
+ ); +} diff --git a/packages/ono/example/barrels/blog/hello-world.tsx b/packages/ono/example/barrels/blog/hello-world.tsx new file mode 100644 index 0000000..2ae48e5 --- /dev/null +++ b/packages/ono/example/barrels/blog/hello-world.tsx @@ -0,0 +1,16 @@ +export const meta = { + title: "Hello World", + date: "2025-01-04", + author: "hashrock", +}; + +export default function HelloWorld() { + return ( +
+

{meta.title}

+

{meta.date} by {meta.author}

+

Welcome to my first blog post built with Ono SSG!

+

This is a simple example of using barrels for content management.

+
+ ); +} diff --git a/packages/ono/example/content.config.js b/packages/ono/example/content.config.js deleted file mode 100644 index 64582a2..0000000 --- a/packages/ono/example/content.config.js +++ /dev/null @@ -1,11 +0,0 @@ -export const collections = { - blog: { - schema: { - title: { type: "string", required: true }, - date: { type: "date", required: true }, - author: { type: "string", required: true }, - tags: { type: "array", items: "string" }, - draft: { type: "boolean", default: false }, - }, - }, -}; diff --git a/packages/ono/example/content/blog/first-post.md b/packages/ono/example/content/blog/first-post.md deleted file mode 100644 index 8995e53..0000000 --- a/packages/ono/example/content/blog/first-post.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -title: My First Blog Post -date: 2024-01-15 -author: John Doe -tags: - - javascript - - web - - tutorial -draft: false ---- - -# Welcome to My Blog - -This is my first blog post using **Ono's Content Collections**! - -## Features - -Content Collections in Ono provide: - -- Frontmatter parsing -- Markdown to HTML conversion -- Type validation -- Easy querying with `getCollection()` and `getEntry()` - -## Code Example - -Here's a simple example: - -```javascript -import { getCollection } from '@hashrock/ono/content'; - -const posts = await getCollection('blog'); -``` - -## Conclusion - -Building static sites with Ono is simple and lightweight! diff --git a/packages/ono/example/content/blog/second-post.md b/packages/ono/example/content/blog/second-post.md deleted file mode 100644 index 57022a6..0000000 --- a/packages/ono/example/content/blog/second-post.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -title: Building with JSX -date: 2024-01-20 -author: Jane Smith -tags: [jsx, react, components] -draft: false ---- - -# Building Static Sites with JSX - -JSX provides a powerful way to build static sites with component-based architecture. - -## Why JSX? - -1. **Familiar syntax** - If you know React, you know JSX -2. **Component reusability** - Build once, use everywhere -3. **Type safety** - Works great with TypeScript - -## Example Component - -```jsx -function BlogPost({ title, date, children }) { - return ( -
-

{title}

- -
{children}
-
- ); -} -``` - -That's all for today! diff --git a/packages/ono/example/pages/blog.jsx b/packages/ono/example/pages/blog.jsx deleted file mode 100644 index 29b3382..0000000 --- a/packages/ono/example/pages/blog.jsx +++ /dev/null @@ -1,69 +0,0 @@ -import { getCollection } from "@hashrock/ono/content"; - -export default async function BlogIndex() { - const posts = await getCollection("blog", (post) => !post.data.draft); - - return ( - - - - - Blog - Ono Content Collections Example - - - -
-

Blog

-

- Powered by Ono Content Collections -

-
- -
- {posts.map((post) => ( -
-
-

- - {post.data.title} - -

-
- - {" ยท "} - {post.data.author} -
- {post.data.tags && post.data.tags.length > 0 && ( -
- {post.data.tags.map((tag) => ( - - {tag} - - ))} -
- )} -
-
-
- ))} -
- - - - - ); -} diff --git a/packages/ono/example/pages/blog/[slug].jsx b/packages/ono/example/pages/blog/[slug].jsx deleted file mode 100644 index 3994cb8..0000000 --- a/packages/ono/example/pages/blog/[slug].jsx +++ /dev/null @@ -1,81 +0,0 @@ -import { getCollection, getEntry } from "@hashrock/ono/content"; - -export async function getStaticPaths() { - const posts = await getCollection("blog"); - return posts.map((post) => ({ - params: { slug: post.slug }, - })); -} - -export default async function BlogPost({ params }) { - const post = await getEntry("blog", params.slug); - - if (!post) { - return ( - - - Post Not Found - - -

404 - Post Not Found

- - - ); - } - - return ( - - - - - {post.data.title} - Blog - - - - - -
-
-

{post.data.title}

-
- - {" ยท "} - {post.data.author} -
- {post.data.tags && post.data.tags.length > 0 && ( -
- {post.data.tags.map((tag) => ( - - {tag} - - ))} -
- )} -
- -
-
- - - - - ); -} diff --git a/packages/ono/package.json b/packages/ono/package.json index 8089551..ecbec6b 100644 --- a/packages/ono/package.json +++ b/packages/ono/package.json @@ -10,7 +10,7 @@ "./renderer": "./src/renderer.js", "./transformer": "./src/transformer.js", "./bundler": "./src/bundler.js", - "./content": "./src/content.js", + "./barrels": "./src/barrels.js", "./browser/compiler": "./src/browser/compiler.js", "./browser/unocss": "./src/browser/unocss.js" }, @@ -54,7 +54,6 @@ "dependencies": { "@unocss/preset-uno": "^66.5.4", "h3": "^2.0.1-rc.5", - "marked": "^16.4.1", "typescript": "^5.9.3", "unocss": "^66.5.4", "ws": "^8.18.3" diff --git a/packages/ono/src/barrels.js b/packages/ono/src/barrels.js new file mode 100644 index 0000000..aff2837 --- /dev/null +++ b/packages/ono/src/barrels.js @@ -0,0 +1,234 @@ +/** + * Barrels - Auto-generated barrel files with type inference + */ +import { readdir, writeFile, mkdir } from "node:fs/promises"; +import { join, basename, dirname, relative } from "node:path"; +import { bundle } from "./bundler.js"; + +/** + * Convert kebab-case or snake_case to camelCase + */ +function toCamelCase(str) { + return str.replace(/[-_]([a-z])/g, (_, c) => c.toUpperCase()); +} + +/** + * Infer TypeScript type from a JavaScript value + */ +function inferType(value) { + if (value === null) return "null"; + if (value === undefined) return "undefined"; + if (Array.isArray(value)) { + if (value.length === 0) return "unknown[]"; + // Infer from first element + return `${inferType(value[0])}[]`; + } + if (value instanceof Date) return "Date"; + if (typeof value === "object") { + // For nested objects, use Record type + return "Record"; + } + return typeof value; // 'string' | 'number' | 'boolean' +} + +/** + * Generate Meta type definition from collected metas + */ +function generateMetaType(metas) { + if (metas.length === 0) return "export type Meta = Record;"; + + // Collect all keys and their occurrence count + const keyCounts = {}; + const keyTypes = {}; + + for (const meta of metas) { + if (!meta) continue; + for (const [key, value] of Object.entries(meta)) { + keyCounts[key] = (keyCounts[key] || 0) + 1; + // Store the first non-undefined value's type + if (!keyTypes[key] && value !== undefined) { + keyTypes[key] = inferType(value); + } + } + } + + // Generate type fields + const fields = Object.entries(keyCounts) + .map(([key, count]) => { + const type = keyTypes[key] || "unknown"; + const optional = count < metas.length; + return ` ${key}${optional ? "?" : ""}: ${type};`; + }) + .join("\n"); + + return `export type Meta = {\n${fields}\n};`; +} + +/** + * Get all entry files from a barrel directory + */ +async function getBarrelEntries(barrelDir) { + const entries = []; + + try { + const files = await readdir(barrelDir, { withFileTypes: true }); + + for (const file of files) { + if (file.isFile() && (file.name.endsWith(".tsx") || file.name.endsWith(".jsx"))) { + const id = basename(file.name, file.name.endsWith(".tsx") ? ".tsx" : ".jsx"); + entries.push({ + id, + file: file.name, + path: join(barrelDir, file.name), + }); + } + } + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + + return entries.sort((a, b) => a.id.localeCompare(b.id)); +} + +/** + * Load meta from an entry file by bundling and evaluating + */ +async function loadMeta(entryPath) { + try { + // Bundle the file to resolve imports + const bundledCode = await bundle(entryPath); + + // Create a temporary module to extract meta + const tempDir = dirname(entryPath); + const tempFile = join(tempDir, `_temp_meta_${Date.now()}.js`); + + // Add JSX runtime and extract meta + const code = ` +function h(tag, props, ...children) { + return { tag, props: props || {}, children }; +} +${bundledCode} +`; + + await writeFile(tempFile, code); + + try { + const moduleUrl = new URL(`file://${tempFile}?t=${Date.now()}`); + const module = await import(moduleUrl.href); + return module.meta || null; + } finally { + // Clean up + await import("node:fs/promises").then((fs) => fs.unlink(tempFile).catch(() => {})); + } + } catch (error) { + console.warn(`Warning: Could not load meta from ${entryPath}:`, error.message); + return null; + } +} + +/** + * Generate a barrel file for a directory + */ +export async function generateBarrel(barrelDir, options = {}) { + const { silent = false } = options; + + const entries = await getBarrelEntries(barrelDir); + + if (entries.length === 0) { + return null; + } + + // Load all metas + const metas = await Promise.all( + entries.map((entry) => loadMeta(entry.path)) + ); + + // Generate type definition + const metaType = generateMetaType(metas); + + // Generate imports with camelCase identifiers + const imports = entries + .map((entry, idx) => { + const camelId = toCamelCase(entry.id); + const hasExportedMeta = metas[idx] !== null; + if (hasExportedMeta) { + return `export { default as ${camelId}, meta as ${camelId}Meta } from './${basename(barrelDir)}/${entry.file}';`; + } + return `export { default as ${camelId} } from './${basename(barrelDir)}/${entry.file}';`; + }) + .join("\n"); + + // Generate entries array (keep original IDs for URL paths) + const entriesArray = `export const entries = [${entries.map((e) => `'${e.id}'`).join(", ")}] as const;`; + + // Generate ID to component mapping + const mapping = entries + .map((entry, idx) => { + const camelId = toCamelCase(entry.id); + const hasExportedMeta = metas[idx] !== null; + if (hasExportedMeta) { + return ` '${entry.id}': { component: ${camelId}, meta: ${camelId}Meta }`; + } + return ` '${entry.id}': { component: ${camelId}, meta: null }`; + }) + .join(",\n"); + const mappingExport = `export const posts = {\n${mapping}\n};`; + const entryIdType = "export type EntryId = typeof entries[number];"; + + // Generate barrel content + const barrelContent = `// Auto-generated barrel file - DO NOT EDIT +// Generated by Ono SSG + +${metaType} + +${imports} + +${entriesArray} +${entryIdType} + +${mappingExport} +`; + + // Write barrel file + const barrelPath = `${barrelDir}.ts`; + await writeFile(barrelPath, barrelContent); + + if (!silent) { + console.log(` Generated: ${relative(process.cwd(), barrelPath)}`); + } + + return barrelPath; +} + +/** + * Generate all barrel files in a directory + */ +export async function generateBarrels(barrelsRoot, options = {}) { + const { silent = false } = options; + + try { + const entries = await readdir(barrelsRoot, { withFileTypes: true }); + const results = []; + + for (const entry of entries) { + if (entry.isDirectory()) { + const barrelDir = join(barrelsRoot, entry.name); + const result = await generateBarrel(barrelDir, { silent: true }); + if (result) { + results.push(result); + } + } + } + + if (!silent && results.length > 0) { + console.log(`Generated ${results.length} barrel file(s)`); + } + + return results; + } catch (error) { + if (error.code === "ENOENT") { + return []; + } + throw error; + } +} diff --git a/packages/ono/src/builder.js b/packages/ono/src/builder.js index 8ecf5e2..748fd78 100644 --- a/packages/ono/src/builder.js +++ b/packages/ono/src/builder.js @@ -1,20 +1,12 @@ /** * Build utilities for Ono SSG */ -import { readFile, writeFile, mkdir, readdir, stat } from "node:fs/promises"; -import { resolve, join, dirname, basename, relative, extname } from "node:path"; -import { transformJSX } from "./transformer.js"; +import { writeFile, mkdir, readdir } from "node:fs/promises"; +import { resolve, join, dirname, basename, relative } from "node:path"; import { renderToString } from "./renderer.js"; import { generateCSSFromFiles } from "./unocss.js"; import { bundle } from "./bundler.js"; -/** - * Check if a route is dynamic (contains [param]) - */ -export function isDynamicRoute(filePath) { - return /\[([^\]]+)\]/.test(filePath); -} - // Inline JSX runtime for bundled output const INLINE_JSX_RUNTIME = ` function flattenChildren(children) { @@ -88,88 +80,6 @@ export async function buildFile(inputFile, options = {}) { return { outputPath, html }; } -/** - * Build a dynamic route (e.g., [slug].jsx) - */ -export async function buildDynamicRoute(inputFile, options = {}) { - const { outputDir = "dist", silent = false } = options; - - const outDir = resolve(process.cwd(), outputDir); - const resolvedInput = resolve(process.cwd(), inputFile); - - // Bundle the file with all its dependencies - const bundledCode = await bundle(resolvedInput); - - // Add inline JSX runtime - const codeWithRuntime = INLINE_JSX_RUNTIME + bundledCode; - - // Write transformed JS temporarily - const tempFile = join(outDir, `_temp_${Date.now()}.js`); - await mkdir(dirname(tempFile), { recursive: true }); - await writeFile(tempFile, codeWithRuntime); - - // Import module - const moduleUrl = new URL(`file://${tempFile}?t=${Date.now()}`); - const module = await import(moduleUrl.href); - - if (!module.getStaticPaths) { - throw new Error( - `Dynamic route ${inputFile} must export a getStaticPaths function` - ); - } - - const pathsData = await module.getStaticPaths(); - const paths = Array.isArray(pathsData) ? pathsData : pathsData.paths || []; - - const App = module.default; - if (!App) { - throw new Error(`No default export found in ${inputFile}`); - } - - const outputs = []; - - for (const pathData of paths) { - const params = pathData.params || {}; - - let vnode = typeof App === "function" ? App({ params }) : App; - if (vnode instanceof Promise) { - vnode = await vnode; - } - - const html = renderToString(vnode); - - // Determine output path from params - const routeDir = dirname(relative(join(process.cwd(), "pages"), resolvedInput)); - const fileName = basename(resolvedInput, ".jsx"); - - // Replace [param] with actual value - let outputPath = fileName; - for (const [key, value] of Object.entries(params)) { - outputPath = outputPath.replace(`[${key}]`, value); - } - - const fullOutputPath = join( - outDir, - routeDir === "." ? "" : routeDir, - `${outputPath}.html` - ); - - await mkdir(dirname(fullOutputPath), { recursive: true }); - await writeFile(fullOutputPath, html); - - outputs.push({ outputPath: fullOutputPath, html, params }); - - if (!silent) { - console.log(` โœ“ ${relative(process.cwd(), fullOutputPath)}`); - } - } - - // Clean up temp file - await import("node:fs/promises").then((fs) => fs.unlink(tempFile).catch(() => {})); - - return outputs; -} - /** * Build multiple JSX files */ @@ -186,22 +96,11 @@ export async function buildFiles(inputPattern, options = {}) { const results = []; for (const file of files) { - if (isDynamicRoute(file)) { - if (!silent) { - const relativePath = relative(process.cwd(), file); - const pathsData = await getDynamicRoutePaths(file); - const count = Array.isArray(pathsData) ? pathsData.length : pathsData.paths?.length || 0; - console.log(`Building dynamic route ${relativePath} (${count} pages)...`); - } - const outputs = await buildDynamicRoute(file, { outputDir, silent: true }); - results.push(...outputs); - } else { - if (!silent) { - console.log(`Building ${relative(process.cwd(), file)}...`); - } - const result = await buildFile(file, { outputDir, unocssConfig, silent: true }); - results.push(result); + if (!silent) { + console.log(`Building ${relative(process.cwd(), file)}...`); } + const result = await buildFile(file, { outputDir, unocssConfig, silent: true }); + results.push(result); } return results; @@ -228,35 +127,6 @@ async function getAllJSXFiles(dir) { return files; } -/** - * Helper to get paths from a dynamic route - */ -/** - * Helper to get paths from a dynamic route - */ -export async function getDynamicRoutePaths(file) { - const outDir = resolve(process.cwd(), "dist"); - - // Bundle the file with all its dependencies - const bundledCode = await bundle(file); - - // Add inline JSX runtime - const codeWithRuntime = INLINE_JSX_RUNTIME + bundledCode; - - const tempFile = join(outDir, `_temp_paths_${Date.now()}.js`); - await mkdir(dirname(tempFile), { recursive: true }); - await writeFile(tempFile, codeWithRuntime); - - const moduleUrl = new URL(`file://${tempFile}?t=${Date.now()}`); - const module = await import(moduleUrl.href); - - const pathsData = module.getStaticPaths ? await module.getStaticPaths() : []; - - await import("node:fs/promises").then((fs) => fs.unlink(tempFile).catch(() => {})); - - return pathsData; -} - /** * Generate UnoCSS file */ diff --git a/packages/ono/src/cli.js b/packages/ono/src/cli.js index 316b91c..37ce7f3 100755 --- a/packages/ono/src/cli.js +++ b/packages/ono/src/cli.js @@ -11,6 +11,7 @@ import { loadUnoConfig } from "./unocss.js"; import { createDevServer } from "./server.js"; import { buildFile, buildFiles, generateUnoCSS } from "./builder.js"; import { watchFile, watchFiles, createWebSocketServer } from "./watcher.js"; +import { generateBarrels } from "./barrels.js"; const args = process.argv.slice(2); const command = args[0]; @@ -75,6 +76,13 @@ Examples: const unocssConfig = await loadUnoConfig(); + // Generate barrel files if barrels directory exists + const barrelsDir = resolve(process.cwd(), "barrels"); + if (existsSync(barrelsDir)) { + console.log("Generating barrel files..."); + await generateBarrels(barrelsDir); + } + // Check if input is a directory or a file const inputPath = resolve(process.cwd(), input); const inputStat = await stat(inputPath); @@ -109,6 +117,13 @@ async function runDevCommand() { const unocssConfig = await loadUnoConfig(); + // Generate barrel files if barrels directory exists + const barrelsDir = resolve(process.cwd(), "barrels"); + if (existsSync(barrelsDir)) { + console.log("Generating barrel files..."); + await generateBarrels(barrelsDir); + } + // Initial build const inputPath = resolve(process.cwd(), input); const inputStat = await stat(inputPath); diff --git a/packages/ono/src/content.js b/packages/ono/src/content.js deleted file mode 100644 index 231bc5f..0000000 --- a/packages/ono/src/content.js +++ /dev/null @@ -1,272 +0,0 @@ -import { readdir, readFile } from "node:fs/promises"; -import { join, relative, sep } from "node:path"; -import { marked } from "marked"; - -/** - * Parse frontmatter from markdown content - * @param {string} content - Raw markdown content - * @returns {{ data: Object, content: string }} Parsed frontmatter and content - */ -function parseFrontmatter(content) { - const frontmatterRegex = /^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/; - const match = content.match(frontmatterRegex); - - if (!match) { - return { data: {}, content }; - } - - const [, frontmatterStr, markdownContent] = match; - const data = {}; - - // Simple YAML-like parser (supports basic key: value pairs) - const lines = frontmatterStr.split("\n"); - let currentKey = null; - let arrayItems = []; - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed) continue; - - // Array item - if (trimmed.startsWith("- ")) { - if (currentKey) { - arrayItems.push(trimmed.slice(2).trim()); - } - continue; - } - - // If we were collecting array items, save them - if (currentKey && arrayItems.length > 0) { - data[currentKey] = arrayItems; - arrayItems = []; - currentKey = null; - } - - // Key-value pair - const colonIndex = trimmed.indexOf(":"); - if (colonIndex > 0) { - const key = trimmed.slice(0, colonIndex).trim(); - let value = trimmed.slice(colonIndex + 1).trim(); - - // Parse value types - if (value === "") { - // Empty value might indicate an array follows - currentKey = key; - continue; - } else if (value === "true") { - value = true; - } else if (value === "false") { - value = false; - } else if (value.startsWith("[") && value.endsWith("]")) { - // JSON array - parse with proper quoting for unquoted strings - try { - // First try parsing as-is (for properly quoted JSON) - value = JSON.parse(value); - } catch { - // If that fails, try parsing as YAML-style array [item1, item2] - try { - const items = value - .slice(1, -1) // Remove [ ] - .split(",") - .map((item) => item.trim()) - .filter((item) => item.length > 0); - value = items; - } catch { - // Keep as string if all parsing fails - } - } - } else if (value.startsWith("{") && value.endsWith("}")) { - // JSON object - try { - value = JSON.parse(value); - } catch { - // Keep as string if parsing fails - } - } else if (/^\d{4}-\d{2}-\d{2}/.test(value)) { - // Date format - value = new Date(value); - } else if (/^\d+$/.test(value)) { - value = parseInt(value, 10); - } else if (/^\d+\.\d+$/.test(value)) { - value = parseFloat(value); - } - - data[key] = value; - currentKey = null; - } - } - - // Handle final array if exists - if (currentKey && arrayItems.length > 0) { - data[currentKey] = arrayItems; - } - - return { data, content: markdownContent }; -} - -/** - * Validate data against schema - * @param {Object} data - Data to validate - * @param {Object} schema - Schema definition - * @returns {{ valid: boolean, errors: string[] }} - */ -function validateSchema(data, schema) { - const errors = []; - - for (const [key, definition] of Object.entries(schema)) { - const value = data[key]; - - // Check required fields - if (definition.required && (value === undefined || value === null)) { - errors.push(`Missing required field: ${key}`); - continue; - } - - // Skip validation if field is not present and not required - if (value === undefined || value === null) { - // Apply default if specified - if (definition.default !== undefined) { - data[key] = definition.default; - } - continue; - } - - // Type validation - const actualType = Array.isArray(value) ? "array" : typeof value === "object" && value instanceof Date ? "date" : typeof value; - - if (definition.type !== actualType) { - errors.push( - `Invalid type for ${key}: expected ${definition.type}, got ${actualType}`, - ); - continue; - } - - // Array item type validation - if (definition.type === "array" && definition.items) { - for (let i = 0; i < value.length; i++) { - const itemType = typeof value[i]; - if (itemType !== definition.items) { - errors.push( - `Invalid array item type for ${key}[${i}]: expected ${definition.items}, got ${itemType}`, - ); - } - } - } - } - - return { valid: errors.length === 0, errors }; -} - -/** - * Generate slug from file path - * @param {string} filePath - File path relative to content directory - * @returns {string} Generated slug - */ -function generateSlug(filePath) { - return filePath - .replace(/\.md$/, "") - .split(sep) - .join("/"); -} - -/** - * Read all markdown files from a directory recursively - * @param {string} dir - Directory path - * @returns {Promise} Array of file paths - */ -async function readMarkdownFiles(dir) { - const files = []; - const entries = await readdir(dir, { withFileTypes: true }); - - for (const entry of entries) { - const fullPath = join(dir, entry.name); - if (entry.isDirectory()) { - files.push(...(await readMarkdownFiles(fullPath))); - } else if (entry.name.endsWith(".md")) { - files.push(fullPath); - } - } - - return files; -} - -/** - * Load content collection configuration - * @param {string} configPath - Path to content.config.js - * @returns {Promise} Configuration object - */ -async function loadConfig(configPath) { - const configFile = configPath || join(process.cwd(), "content.config.js"); - try { - // Convert to file URL for proper import - const fileUrl = new URL(`file://${configFile}`); - const config = await import(fileUrl.href); - return config.collections || {}; - } catch { - return {}; - } -} - -/** - * Get all entries from a collection - * @param {string} collection - Collection name - * @param {Function} [filter] - Optional filter function - * @returns {Promise} Array of collection entries - */ -export async function getCollection(collection, filter) { - const contentDir = join(process.cwd(), "content", collection); - const config = await loadConfig(); - const schema = config[collection]?.schema; - - try { - const files = await readMarkdownFiles(contentDir); - const entries = []; - - for (const file of files) { - const content = await readFile(file, "utf-8"); - const { data, content: markdown } = parseFrontmatter(content); - - // Validate against schema if provided - if (schema) { - const validation = validateSchema(data, schema); - if (!validation.valid) { - console.warn(`Validation errors in ${file}:`, validation.errors); - } - } - - const html = marked.parse(markdown); - const relativePath = relative(contentDir, file); - const slug = generateSlug(relativePath); - - entries.push({ - slug, - data, - html, - file, - }); - } - - // Apply filter if provided - if (filter) { - return entries.filter(filter); - } - - return entries; - } catch (error) { - if (error.code === "ENOENT") { - return []; - } - throw error; - } -} - -/** - * Get a single entry from a collection - * @param {string} collection - Collection name - * @param {string} slug - Entry slug - * @returns {Promise} Collection entry or null if not found - */ -export async function getEntry(collection, slug) { - const entries = await getCollection(collection); - return entries.find((entry) => entry.slug === slug) || null; -} diff --git a/packages/ono/src/watcher.js b/packages/ono/src/watcher.js index 44071e5..8c7818a 100644 --- a/packages/ono/src/watcher.js +++ b/packages/ono/src/watcher.js @@ -5,7 +5,8 @@ import { watch } from "node:fs"; import { resolve, join, relative, extname } from "node:path"; import { readdir } from "node:fs/promises"; import { WebSocketServer } from "ws"; -import { buildFile, buildFiles, buildDynamicRoute, generateUnoCSS, isDynamicRoute, getDynamicRoutePaths } from "./builder.js"; +import { buildFile, buildFiles, generateUnoCSS } from "./builder.js"; +import { generateBarrel } from "./barrels.js"; /** * Create a WebSocket server for live reload @@ -60,16 +61,7 @@ export async function watchFiles(inputPattern, options = {}) { console.log(`\n๐Ÿ“ File changed: ${relative(process.cwd(), file)}`); console.log("๐Ÿ”„ Rebuilding...\n"); - if (isDynamicRoute(file)) { - const relativePath = relative(process.cwd(), file); - const pathsData = await getDynamicRoutePaths(file); - const count = Array.isArray(pathsData) ? pathsData.length : pathsData.paths?.length || 0; - console.log(`Building dynamic route ${relativePath} (${count} pages)...`); - await buildDynamicRoute(file, { outputDir, silent: true }); - } else { - await buildFile(file, { outputDir, unocssConfig, silent: false }); - } - + await buildFile(file, { outputDir, unocssConfig, silent: false }); await generateUnoCSS({ outputDir, unocssConfig, silent: false }); if (onRebuild) { @@ -94,8 +86,9 @@ export async function watchFiles(inputPattern, options = {}) { }); // Watch public directory if it exists + let publicWatcher; try { - const publicWatcher = watch(publicDir, { recursive: true }, async (eventType, filename) => { + publicWatcher = watch(publicDir, { recursive: true }, async (eventType, filename) => { if (filename) { console.log(`\n๐Ÿ“ Public file changed: ${filename}`); console.log("๐Ÿ”„ Rebuilding...\n"); @@ -113,12 +106,47 @@ export async function watchFiles(inputPattern, options = {}) { } } }); - - return { watcher, publicWatcher }; } catch (error) { // Public directory might not exist - return { watcher }; } + + // Watch barrels directory if it exists + const barrelsDir = resolve(process.cwd(), "barrels"); + let barrelsWatcher; + try { + barrelsWatcher = watch(barrelsDir, { recursive: true }, async (eventType, filename) => { + if (filename && (filename.endsWith(".tsx") || filename.endsWith(".jsx"))) { + // Extract barrel name from path (e.g., "blog/post1.tsx" -> "blog") + const barrelName = filename.split("/")[0]; + const barrelDir = join(barrelsDir, barrelName); + + console.log(`\n๐Ÿ“ Barrel file changed: ${filename}`); + console.log("๐Ÿ”„ Regenerating barrel...\n"); + + try { + await generateBarrel(barrelDir); + + // Rebuild pages that might depend on this barrel + await buildFiles(inputPattern, { outputDir, unocssConfig, silent: false }); + await generateUnoCSS({ outputDir, unocssConfig, silent: false }); + + if (onRebuild) { + await onRebuild(); + } + + if (wss) { + broadcastReload(wss); + } + } catch (error) { + console.error("โŒ Barrel generation error:", error.message); + } + } + }); + } catch (error) { + // Barrels directory might not exist + } + + return { watcher, publicWatcher, barrelsWatcher }; } /** @@ -140,12 +168,7 @@ export async function watchFile(inputFile, options = {}) { console.log(`\n๐Ÿ“ File changed: ${inputFile}`); console.log("๐Ÿ”„ Rebuilding...\n"); - if (isDynamicRoute(inputFile)) { - await buildDynamicRoute(resolvedInput, { outputDir, silent: false }); - } else { - await buildFile(resolvedInput, { outputDir, unocssConfig, silent: false }); - } - + await buildFile(resolvedInput, { outputDir, unocssConfig, silent: false }); await generateUnoCSS({ outputDir, unocssConfig, silent: false }); if (onRebuild) { diff --git a/packages/ono/test-content/content.config.js b/packages/ono/test-content/content.config.js deleted file mode 100644 index d06be2a..0000000 --- a/packages/ono/test-content/content.config.js +++ /dev/null @@ -1,11 +0,0 @@ -export const collections = { - blog: { - schema: { - title: { type: "string", required: true }, - date: { type: "date", required: true }, - author: { type: "string", required: true }, - tags: { type: "array", items: "string" }, - draft: { type: "boolean", default: false } - } - } -}; \ No newline at end of file diff --git a/packages/ono/test-content/content/blog/2024/january/nested-post.md b/packages/ono/test-content/content/blog/2024/january/nested-post.md deleted file mode 100644 index 6d2e1d6..0000000 --- a/packages/ono/test-content/content/blog/2024/january/nested-post.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -title: Nested Post -date: 2024-01-15 -author: Alice ---- - -# Nested - -This is in a subdirectory. \ No newline at end of file diff --git a/packages/ono/test-content/content/blog/post1.md b/packages/ono/test-content/content/blog/post1.md deleted file mode 100644 index 4fd1480..0000000 --- a/packages/ono/test-content/content/blog/post1.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -title: First Post -date: 2024-01-01 -author: John -tags: - - javascript - - web -draft: false ---- - -# Hello World - -This is the first post. \ No newline at end of file diff --git a/packages/ono/test-content/content/blog/post2.md b/packages/ono/test-content/content/blog/post2.md deleted file mode 100644 index 0fe8a2b..0000000 --- a/packages/ono/test-content/content/blog/post2.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -title: Second Post -date: 2024-01-02 -author: Jane -tags: [react, jsx] -draft: true ---- - -# React JSX - -This is about JSX. \ No newline at end of file diff --git a/packages/ono/test-content/content/blog/post3.md b/packages/ono/test-content/content/blog/post3.md deleted file mode 100644 index a2be33a..0000000 --- a/packages/ono/test-content/content/blog/post3.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -title: Third Post -date: 2024-01-03 -author: Bob ---- - -# No tags - -This post has no tags. \ No newline at end of file diff --git a/packages/ono/test/content.test.js b/packages/ono/test/content.test.js deleted file mode 100644 index 393dd82..0000000 --- a/packages/ono/test/content.test.js +++ /dev/null @@ -1,197 +0,0 @@ -import { describe, it, beforeEach } from "node:test"; -import assert from "node:assert"; -import { mkdir, writeFile, rm } from "node:fs/promises"; -import { join } from "node:path"; -import { getCollection, getEntry } from "../src/content.js"; - -const TEST_DIR = join(process.cwd(), "test-content"); -const BLOG_DIR = join(TEST_DIR, "content", "blog"); - -describe("Content Collections", () => { - beforeEach(async () => { - // Clean up and create test directories - await rm(TEST_DIR, { recursive: true, force: true }); - await mkdir(BLOG_DIR, { recursive: true }); - - // Create test markdown files - await writeFile( - join(BLOG_DIR, "post1.md"), - `--- -title: First Post -date: 2024-01-01 -author: John -tags: - - javascript - - web -draft: false ---- - -# Hello World - -This is the first post.`, - ); - - await writeFile( - join(BLOG_DIR, "post2.md"), - `--- -title: Second Post -date: 2024-01-02 -author: Jane -tags: [react, jsx] -draft: true ---- - -# React JSX - -This is about JSX.`, - ); - - await writeFile( - join(BLOG_DIR, "post3.md"), - `--- -title: Third Post -date: 2024-01-03 -author: Bob ---- - -# No tags - -This post has no tags.`, - ); - - // Create config file - await writeFile( - join(TEST_DIR, "content.config.js"), - `export const collections = { - blog: { - schema: { - title: { type: "string", required: true }, - date: { type: "date", required: true }, - author: { type: "string", required: true }, - tags: { type: "array", items: "string" }, - draft: { type: "boolean", default: false } - } - } -};`, - ); - - // Change to test directory - process.chdir(TEST_DIR); - }); - - describe("getCollection", () => { - it("should return all entries from a collection", async () => { - const entries = await getCollection("blog"); - - assert.strictEqual(entries.length, 3); - assert.ok(entries.every((e) => e.slug && e.data && e.html && e.file)); - }); - - it("should parse frontmatter correctly", async () => { - const entries = await getCollection("blog"); - const post1 = entries.find((e) => e.slug === "post1"); - - assert.strictEqual(post1.data.title, "First Post"); - assert.ok(post1.data.date instanceof Date); - assert.strictEqual(post1.data.author, "John"); - assert.deepStrictEqual(post1.data.tags, ["javascript", "web"]); - assert.strictEqual(post1.data.draft, false); - }); - - it("should parse JSON-style arrays in frontmatter", async () => { - const entries = await getCollection("blog"); - const post2 = entries.find((e) => e.slug === "post2"); - - assert.deepStrictEqual(post2.data.tags, ["react", "jsx"]); - }); - - it("should apply default values from schema", async () => { - const entries = await getCollection("blog"); - const post3 = entries.find((e) => e.slug === "post3"); - - assert.strictEqual(post3.data.draft, false); // default value - }); - - it("should convert markdown to HTML", async () => { - const entries = await getCollection("blog"); - const post1 = entries.find((e) => e.slug === "post1"); - - assert.ok(post1.html.includes("")); - assert.ok(post1.html.includes("first post")); - }); - - it("should filter entries with filter function", async () => { - const entries = await getCollection("blog", (post) => !post.data.draft); - - assert.strictEqual(entries.length, 2); - assert.ok(entries.every((e) => e.data.draft === false)); - }); - - it("should return empty array for non-existent collection", async () => { - const entries = await getCollection("nonexistent"); - - assert.strictEqual(entries.length, 0); - }); - - it("should generate correct slugs", async () => { - const entries = await getCollection("blog"); - const slugs = entries.map((e) => e.slug).sort(); - - assert.deepStrictEqual(slugs, ["post1", "post2", "post3"]); - }); - }); - - describe("getEntry", () => { - it("should return a single entry by slug", async () => { - const entry = await getEntry("blog", "post1"); - - assert.ok(entry); - assert.strictEqual(entry.slug, "post1"); - assert.strictEqual(entry.data.title, "First Post"); - }); - - it("should return null for non-existent slug", async () => { - const entry = await getEntry("blog", "nonexistent"); - - assert.strictEqual(entry, null); - }); - }); - - describe("nested directories", () => { - beforeEach(async () => { - const nestedDir = join(BLOG_DIR, "2024", "january"); - await mkdir(nestedDir, { recursive: true }); - - await writeFile( - join(nestedDir, "nested-post.md"), - `--- -title: Nested Post -date: 2024-01-15 -author: Alice ---- - -# Nested - -This is in a subdirectory.`, - ); - }); - - it("should read markdown files from nested directories", async () => { - const entries = await getCollection("blog"); - - assert.strictEqual(entries.length, 4); - const nested = entries.find((e) => e.slug === "2024/january/nested-post"); - assert.ok(nested); - assert.strictEqual(nested.data.title, "Nested Post"); - }); - - it("should generate slugs with directory structure", async () => { - const entry = await getEntry("blog", "2024/january/nested-post"); - - assert.ok(entry); - assert.strictEqual(entry.data.title, "Nested Post"); - }); - }); -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df24808..7ea75a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,9 +24,6 @@ importers: h3: specifier: ^2.0.1-rc.5 version: 2.0.1-rc.7 - marked: - specifier: ^16.4.1 - version: 16.4.2 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -749,11 +746,6 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - marked@16.4.2: - resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} - engines: {node: '>= 20'} - hasBin: true - mdn-data@2.12.2: resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} @@ -1566,8 +1558,6 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - marked@16.4.2: {} - mdn-data@2.12.2: {} mlly@1.8.0: