@hashrock/ono 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/content.js ADDED
@@ -0,0 +1,272 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { join, relative, sep } from "node:path";
3
+ import { marked } from "marked";
4
+
5
+ /**
6
+ * Parse frontmatter from markdown content
7
+ * @param {string} content - Raw markdown content
8
+ * @returns {{ data: Object, content: string }} Parsed frontmatter and content
9
+ */
10
+ function parseFrontmatter(content) {
11
+ const frontmatterRegex = /^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/;
12
+ const match = content.match(frontmatterRegex);
13
+
14
+ if (!match) {
15
+ return { data: {}, content };
16
+ }
17
+
18
+ const [, frontmatterStr, markdownContent] = match;
19
+ const data = {};
20
+
21
+ // Simple YAML-like parser (supports basic key: value pairs)
22
+ const lines = frontmatterStr.split("\n");
23
+ let currentKey = null;
24
+ let arrayItems = [];
25
+
26
+ for (const line of lines) {
27
+ const trimmed = line.trim();
28
+ if (!trimmed) continue;
29
+
30
+ // Array item
31
+ if (trimmed.startsWith("- ")) {
32
+ if (currentKey) {
33
+ arrayItems.push(trimmed.slice(2).trim());
34
+ }
35
+ continue;
36
+ }
37
+
38
+ // If we were collecting array items, save them
39
+ if (currentKey && arrayItems.length > 0) {
40
+ data[currentKey] = arrayItems;
41
+ arrayItems = [];
42
+ currentKey = null;
43
+ }
44
+
45
+ // Key-value pair
46
+ const colonIndex = trimmed.indexOf(":");
47
+ if (colonIndex > 0) {
48
+ const key = trimmed.slice(0, colonIndex).trim();
49
+ let value = trimmed.slice(colonIndex + 1).trim();
50
+
51
+ // Parse value types
52
+ if (value === "") {
53
+ // Empty value might indicate an array follows
54
+ currentKey = key;
55
+ continue;
56
+ } else if (value === "true") {
57
+ value = true;
58
+ } else if (value === "false") {
59
+ value = false;
60
+ } else if (value.startsWith("[") && value.endsWith("]")) {
61
+ // JSON array - parse with proper quoting for unquoted strings
62
+ try {
63
+ // First try parsing as-is (for properly quoted JSON)
64
+ value = JSON.parse(value);
65
+ } catch {
66
+ // If that fails, try parsing as YAML-style array [item1, item2]
67
+ try {
68
+ const items = value
69
+ .slice(1, -1) // Remove [ ]
70
+ .split(",")
71
+ .map((item) => item.trim())
72
+ .filter((item) => item.length > 0);
73
+ value = items;
74
+ } catch {
75
+ // Keep as string if all parsing fails
76
+ }
77
+ }
78
+ } else if (value.startsWith("{") && value.endsWith("}")) {
79
+ // JSON object
80
+ try {
81
+ value = JSON.parse(value);
82
+ } catch {
83
+ // Keep as string if parsing fails
84
+ }
85
+ } else if (/^\d{4}-\d{2}-\d{2}/.test(value)) {
86
+ // Date format
87
+ value = new Date(value);
88
+ } else if (/^\d+$/.test(value)) {
89
+ value = parseInt(value, 10);
90
+ } else if (/^\d+\.\d+$/.test(value)) {
91
+ value = parseFloat(value);
92
+ }
93
+
94
+ data[key] = value;
95
+ currentKey = null;
96
+ }
97
+ }
98
+
99
+ // Handle final array if exists
100
+ if (currentKey && arrayItems.length > 0) {
101
+ data[currentKey] = arrayItems;
102
+ }
103
+
104
+ return { data, content: markdownContent };
105
+ }
106
+
107
+ /**
108
+ * Validate data against schema
109
+ * @param {Object} data - Data to validate
110
+ * @param {Object} schema - Schema definition
111
+ * @returns {{ valid: boolean, errors: string[] }}
112
+ */
113
+ function validateSchema(data, schema) {
114
+ const errors = [];
115
+
116
+ for (const [key, definition] of Object.entries(schema)) {
117
+ const value = data[key];
118
+
119
+ // Check required fields
120
+ if (definition.required && (value === undefined || value === null)) {
121
+ errors.push(`Missing required field: ${key}`);
122
+ continue;
123
+ }
124
+
125
+ // Skip validation if field is not present and not required
126
+ if (value === undefined || value === null) {
127
+ // Apply default if specified
128
+ if (definition.default !== undefined) {
129
+ data[key] = definition.default;
130
+ }
131
+ continue;
132
+ }
133
+
134
+ // Type validation
135
+ const actualType = Array.isArray(value) ? "array" : typeof value === "object" && value instanceof Date ? "date" : typeof value;
136
+
137
+ if (definition.type !== actualType) {
138
+ errors.push(
139
+ `Invalid type for ${key}: expected ${definition.type}, got ${actualType}`,
140
+ );
141
+ continue;
142
+ }
143
+
144
+ // Array item type validation
145
+ if (definition.type === "array" && definition.items) {
146
+ for (let i = 0; i < value.length; i++) {
147
+ const itemType = typeof value[i];
148
+ if (itemType !== definition.items) {
149
+ errors.push(
150
+ `Invalid array item type for ${key}[${i}]: expected ${definition.items}, got ${itemType}`,
151
+ );
152
+ }
153
+ }
154
+ }
155
+ }
156
+
157
+ return { valid: errors.length === 0, errors };
158
+ }
159
+
160
+ /**
161
+ * Generate slug from file path
162
+ * @param {string} filePath - File path relative to content directory
163
+ * @returns {string} Generated slug
164
+ */
165
+ function generateSlug(filePath) {
166
+ return filePath
167
+ .replace(/\.md$/, "")
168
+ .split(sep)
169
+ .join("/");
170
+ }
171
+
172
+ /**
173
+ * Read all markdown files from a directory recursively
174
+ * @param {string} dir - Directory path
175
+ * @returns {Promise<string[]>} Array of file paths
176
+ */
177
+ async function readMarkdownFiles(dir) {
178
+ const files = [];
179
+ const entries = await readdir(dir, { withFileTypes: true });
180
+
181
+ for (const entry of entries) {
182
+ const fullPath = join(dir, entry.name);
183
+ if (entry.isDirectory()) {
184
+ files.push(...(await readMarkdownFiles(fullPath)));
185
+ } else if (entry.name.endsWith(".md")) {
186
+ files.push(fullPath);
187
+ }
188
+ }
189
+
190
+ return files;
191
+ }
192
+
193
+ /**
194
+ * Load content collection configuration
195
+ * @param {string} configPath - Path to content.config.js
196
+ * @returns {Promise<Object>} Configuration object
197
+ */
198
+ async function loadConfig(configPath) {
199
+ const configFile = configPath || join(process.cwd(), "content.config.js");
200
+ try {
201
+ // Convert to file URL for proper import
202
+ const fileUrl = new URL(`file://${configFile}`);
203
+ const config = await import(fileUrl.href);
204
+ return config.collections || {};
205
+ } catch {
206
+ return {};
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Get all entries from a collection
212
+ * @param {string} collection - Collection name
213
+ * @param {Function} [filter] - Optional filter function
214
+ * @returns {Promise<Array>} Array of collection entries
215
+ */
216
+ export async function getCollection(collection, filter) {
217
+ const contentDir = join(process.cwd(), "content", collection);
218
+ const config = await loadConfig();
219
+ const schema = config[collection]?.schema;
220
+
221
+ try {
222
+ const files = await readMarkdownFiles(contentDir);
223
+ const entries = [];
224
+
225
+ for (const file of files) {
226
+ const content = await readFile(file, "utf-8");
227
+ const { data, content: markdown } = parseFrontmatter(content);
228
+
229
+ // Validate against schema if provided
230
+ if (schema) {
231
+ const validation = validateSchema(data, schema);
232
+ if (!validation.valid) {
233
+ console.warn(`Validation errors in ${file}:`, validation.errors);
234
+ }
235
+ }
236
+
237
+ const html = marked.parse(markdown);
238
+ const relativePath = relative(contentDir, file);
239
+ const slug = generateSlug(relativePath);
240
+
241
+ entries.push({
242
+ slug,
243
+ data,
244
+ html,
245
+ file,
246
+ });
247
+ }
248
+
249
+ // Apply filter if provided
250
+ if (filter) {
251
+ return entries.filter(filter);
252
+ }
253
+
254
+ return entries;
255
+ } catch (error) {
256
+ if (error.code === "ENOENT") {
257
+ return [];
258
+ }
259
+ throw error;
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Get a single entry from a collection
265
+ * @param {string} collection - Collection name
266
+ * @param {string} slug - Entry slug
267
+ * @returns {Promise<Object|null>} Collection entry or null if not found
268
+ */
269
+ export async function getEntry(collection, slug) {
270
+ const entries = await getCollection(collection);
271
+ return entries.find((entry) => entry.slug === slug) || null;
272
+ }
@@ -41,5 +41,24 @@ export function createElement(tag, props, ...children) {
41
41
  };
42
42
  }
43
43
 
44
+ /**
45
+ * JSX runtime function (react-jsx transform)
46
+ * @param {string|Function} tag - HTML tag name or component function
47
+ * @param {Object} props - Element properties/attributes (includes children)
48
+ * @returns {Object} VNode object
49
+ */
50
+ export function jsx(tag, props) {
51
+ const { children, ...restProps } = props || {};
52
+ const childrenArray = children !== undefined ? (Array.isArray(children) ? children : [children]) : [];
53
+ return {
54
+ tag,
55
+ props: restProps,
56
+ children: flattenChildren(childrenArray)
57
+ };
58
+ }
59
+
60
+ // jsxs is the same as jsx (for elements with multiple children)
61
+ export const jsxs = jsx;
62
+
44
63
  // Alias for compatibility
45
64
  export const h = createElement;
package/src/renderer.js CHANGED
@@ -46,11 +46,13 @@ function styleToString(style) {
46
46
  * Render attributes to string
47
47
  */
48
48
  function renderAttributes(props) {
49
+ if (!props) return '';
50
+
49
51
  const attributes = [];
50
52
 
51
53
  for (const [key, value] of Object.entries(props)) {
52
54
  // Skip special props
53
- if (key === 'children') continue;
55
+ if (key === 'children' || key === 'dangerouslySetInnerHTML') continue;
54
56
 
55
57
  // Handle className -> class conversion
56
58
  if (key === 'className') {
@@ -119,10 +121,15 @@ export function renderToString(vnode) {
119
121
  return `<${tag}${attrs} />`;
120
122
  }
121
123
 
124
+ // Handle dangerouslySetInnerHTML
125
+ if (props && props.dangerouslySetInnerHTML && props.dangerouslySetInnerHTML.__html) {
126
+ return `<${tag}${attrs}>${props.dangerouslySetInnerHTML.__html}</${tag}>`;
127
+ }
128
+
122
129
  // Render children
123
- const childrenHtml = children
124
- .map(child => renderToString(child))
125
- .join('');
130
+ const childrenHtml = children && children.length > 0
131
+ ? children.map(child => renderToString(child)).join('')
132
+ : '';
126
133
 
127
134
  return `<${tag}${attrs}>${childrenHtml}</${tag}>`;
128
135
  }
package/src/server.js ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Dev server using h3
3
+ */
4
+ import { createApp, createRouter, eventHandler, setResponseStatus, setResponseHeader, createError } from "h3";
5
+ import { toNodeHandler } from "h3/node";
6
+ import { createServer } from "node:http";
7
+ import { resolve, join, extname } from "node:path";
8
+ import { readFile } from "node:fs/promises";
9
+
10
+ /**
11
+ * Create a development server
12
+ * @param {object} options - Server options
13
+ * @param {string} options.outputDir - Output directory to serve
14
+ * @param {number} options.port - HTTP port
15
+ * @param {string} options.mode - Server mode: 'pages' or 'single'
16
+ * @param {string} options.indexFile - Index file for single mode
17
+ * @returns {Promise<object>} Server instance
18
+ */
19
+ export async function createDevServer(options) {
20
+ const { outputDir = "dist", port = 3000, mode = "pages", indexFile = "index.html" } = options;
21
+
22
+ const outDir = resolve(process.cwd(), outputDir);
23
+
24
+ const app = createApp();
25
+
26
+ // Serve static files from output directory
27
+ app.use(
28
+ "/**",
29
+ eventHandler(async (event) => {
30
+ try {
31
+ const url = event.path || "/";
32
+ let filePath;
33
+
34
+ if (mode === "single") {
35
+ // Single file mode: serve specific file for root
36
+ if (url === "/" || url === "") {
37
+ filePath = join(outDir, indexFile);
38
+ } else {
39
+ filePath = join(outDir, url);
40
+ }
41
+ } else {
42
+ // Pages mode: default routing
43
+ if (url === "/" || url === "") {
44
+ filePath = join(outDir, "index.html");
45
+ } else {
46
+ filePath = join(outDir, url);
47
+ }
48
+ }
49
+
50
+ const content = await readFile(filePath);
51
+ const ext = extname(filePath);
52
+
53
+ const contentTypes = {
54
+ ".html": "text/html; charset=utf-8",
55
+ ".css": "text/css; charset=utf-8",
56
+ ".js": "text/javascript; charset=utf-8",
57
+ ".json": "application/json; charset=utf-8",
58
+ ".png": "image/png",
59
+ ".jpg": "image/jpeg",
60
+ ".jpeg": "image/jpeg",
61
+ ".gif": "image/gif",
62
+ ".svg": "image/svg+xml",
63
+ ".ico": "image/x-icon",
64
+ ".woff": "font/woff",
65
+ ".woff2": "font/woff2",
66
+ ".ttf": "font/ttf",
67
+ ".eot": "application/vnd.ms-fontobject",
68
+ ".webp": "image/webp",
69
+ };
70
+
71
+ setResponseHeader(event, "Content-Type", contentTypes[ext] || "application/octet-stream");
72
+ return content;
73
+ } catch (error) {
74
+ console.error("Server error:", error);
75
+ if (error.code === "ENOENT") {
76
+ throw createError({
77
+ statusCode: 404,
78
+ statusMessage: "Not Found",
79
+ message: `File not found: ${error.path}`,
80
+ });
81
+ } else {
82
+ throw createError({
83
+ statusCode: 500,
84
+ statusMessage: "Internal Server Error",
85
+ message: error.message,
86
+ });
87
+ }
88
+ }
89
+ })
90
+ );
91
+
92
+ const server = createServer(toNodeHandler(app));
93
+
94
+ return new Promise((resolve) => {
95
+ server.listen(port, () => {
96
+ resolve({ server, app, port });
97
+ });
98
+ });
99
+ }
package/src/unocss.js CHANGED
@@ -5,6 +5,29 @@
5
5
  import { createGenerator, presetUno } from "unocss";
6
6
  import fs from "node:fs/promises";
7
7
  import path from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = path.dirname(__filename);
12
+
13
+ /**
14
+ * Get the Tailwind reset CSS
15
+ * @returns {Promise<string>} Reset CSS content
16
+ */
17
+ async function getResetCSS() {
18
+ const resetPath = path.resolve(__dirname, "../node_modules/@unocss/reset/tailwind.css");
19
+ try {
20
+ return await fs.readFile(resetPath, "utf-8");
21
+ } catch {
22
+ // Fallback: try to find it relative to the package
23
+ try {
24
+ const fallbackPath = new URL("../node_modules/@unocss/reset/tailwind.css", import.meta.url);
25
+ return await fs.readFile(fileURLToPath(fallbackPath), "utf-8");
26
+ } catch {
27
+ return "";
28
+ }
29
+ }
30
+ }
8
31
 
9
32
  /**
10
33
  * Create UnoCSS generator with default config
@@ -50,7 +73,7 @@ export async function generateCSS(html, config = {}) {
50
73
  * Extract and generate UnoCSS for multiple HTML files
51
74
  * @param {string[]} htmlFiles - Array of HTML file paths
52
75
  * @param {object} config - UnoCSS configuration
53
- * @returns {Promise<string>} Combined generated CSS
76
+ * @returns {Promise<string>} Combined generated CSS with reset
54
77
  */
55
78
  export async function generateCSSFromFiles(htmlFiles, config = {}) {
56
79
  const uno = await createUnoGenerator(config);
@@ -71,5 +94,8 @@ export async function generateCSSFromFiles(htmlFiles, config = {}) {
71
94
 
72
95
  // Generate CSS
73
96
  const { css } = await uno.generate(combinedHTML);
74
- return css;
97
+
98
+ // Prepend reset CSS
99
+ const resetCSS = await getResetCSS();
100
+ return resetCSS + "\n" + css;
75
101
  }
package/src/watcher.js ADDED
@@ -0,0 +1,167 @@
1
+ /**
2
+ * File watcher utilities for Ono SSG
3
+ */
4
+ import { watch } from "node:fs";
5
+ import { resolve, join, relative, extname } from "node:path";
6
+ import { readdir } from "node:fs/promises";
7
+ import { WebSocketServer } from "ws";
8
+ import { buildFile, buildFiles, buildDynamicRoute, generateUnoCSS, isDynamicRoute, getDynamicRoutePaths } from "./builder.js";
9
+
10
+ /**
11
+ * Create a WebSocket server for live reload
12
+ */
13
+ export function createWebSocketServer(port = 35729) {
14
+ let wss;
15
+ let actualPort = port;
16
+
17
+ try {
18
+ wss = new WebSocketServer({ port });
19
+ } catch (error) {
20
+ if (error.code === "EADDRINUSE") {
21
+ actualPort = port + 1;
22
+ console.log(`ℹ️ WebSocket port ${port} is busy, using port ${actualPort} instead`);
23
+ wss = new WebSocketServer({ port: actualPort });
24
+ } else {
25
+ throw error;
26
+ }
27
+ }
28
+
29
+ return { wss, port: actualPort };
30
+ }
31
+
32
+ /**
33
+ * Broadcast reload message to all connected clients
34
+ */
35
+ export function broadcastReload(wss) {
36
+ wss.clients.forEach((client) => {
37
+ if (client.readyState === 1) {
38
+ client.send("reload");
39
+ }
40
+ });
41
+ }
42
+
43
+ /**
44
+ * Watch for file changes and rebuild
45
+ */
46
+ export async function watchFiles(inputPattern, options = {}) {
47
+ const { outputDir = "dist", unocssConfig, onRebuild, wss } = options;
48
+
49
+ const pagesDir = resolve(process.cwd(), inputPattern);
50
+ const publicDir = resolve(process.cwd(), "public");
51
+
52
+ console.log(`👀 Watching for changes in ${inputPattern}/ and public/...`);
53
+
54
+ // Debounce rebuilds
55
+ let rebuildTimeout;
56
+ const debouncedRebuild = async (file) => {
57
+ clearTimeout(rebuildTimeout);
58
+ rebuildTimeout = setTimeout(async () => {
59
+ try {
60
+ console.log(`\n📝 File changed: ${relative(process.cwd(), file)}`);
61
+ console.log("🔄 Rebuilding...\n");
62
+
63
+ if (isDynamicRoute(file)) {
64
+ const relativePath = relative(process.cwd(), file);
65
+ const pathsData = await getDynamicRoutePaths(file);
66
+ const count = Array.isArray(pathsData) ? pathsData.length : pathsData.paths?.length || 0;
67
+ console.log(`Building dynamic route ${relativePath} (${count} pages)...`);
68
+ await buildDynamicRoute(file, { outputDir, silent: true });
69
+ } else {
70
+ await buildFile(file, { outputDir, unocssConfig, silent: false });
71
+ }
72
+
73
+ await generateUnoCSS({ outputDir, unocssConfig, silent: false });
74
+
75
+ if (onRebuild) {
76
+ await onRebuild();
77
+ }
78
+
79
+ if (wss) {
80
+ broadcastReload(wss);
81
+ }
82
+ } catch (error) {
83
+ console.error("❌ Build error:", error.message);
84
+ }
85
+ }, 100);
86
+ };
87
+
88
+ // Watch pages directory
89
+ const watcher = watch(pagesDir, { recursive: true }, async (eventType, filename) => {
90
+ if (filename && filename.endsWith(".jsx")) {
91
+ const filePath = join(pagesDir, filename);
92
+ await debouncedRebuild(filePath);
93
+ }
94
+ });
95
+
96
+ // Watch public directory if it exists
97
+ try {
98
+ const publicWatcher = watch(publicDir, { recursive: true }, async (eventType, filename) => {
99
+ if (filename) {
100
+ console.log(`\n📝 Public file changed: ${filename}`);
101
+ console.log("🔄 Rebuilding...\n");
102
+
103
+ // Rebuild all files to update references
104
+ await buildFiles(inputPattern, { outputDir, unocssConfig, silent: false });
105
+ await generateUnoCSS({ outputDir, unocssConfig, silent: false });
106
+
107
+ if (onRebuild) {
108
+ await onRebuild();
109
+ }
110
+
111
+ if (wss) {
112
+ broadcastReload(wss);
113
+ }
114
+ }
115
+ });
116
+
117
+ return { watcher, publicWatcher };
118
+ } catch (error) {
119
+ // Public directory might not exist
120
+ return { watcher };
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Watch a single file for changes
126
+ */
127
+ export async function watchFile(inputFile, options = {}) {
128
+ const { outputDir = "dist", unocssConfig, onRebuild, wss } = options;
129
+
130
+ const resolvedInput = resolve(process.cwd(), inputFile);
131
+
132
+ console.log(`👀 Watching for changes in ${inputFile}...`);
133
+
134
+ // Debounce rebuilds
135
+ let rebuildTimeout;
136
+ const debouncedRebuild = async () => {
137
+ clearTimeout(rebuildTimeout);
138
+ rebuildTimeout = setTimeout(async () => {
139
+ try {
140
+ console.log(`\n📝 File changed: ${inputFile}`);
141
+ console.log("🔄 Rebuilding...\n");
142
+
143
+ if (isDynamicRoute(inputFile)) {
144
+ await buildDynamicRoute(resolvedInput, { outputDir, silent: false });
145
+ } else {
146
+ await buildFile(resolvedInput, { outputDir, unocssConfig, silent: false });
147
+ }
148
+
149
+ await generateUnoCSS({ outputDir, unocssConfig, silent: false });
150
+
151
+ if (onRebuild) {
152
+ await onRebuild();
153
+ }
154
+
155
+ if (wss) {
156
+ broadcastReload(wss);
157
+ }
158
+ } catch (error) {
159
+ console.error("❌ Build error:", error.message);
160
+ }
161
+ }, 100);
162
+ };
163
+
164
+ const watcher = watch(resolvedInput, debouncedRebuild);
165
+
166
+ return { watcher };
167
+ }