@hashrock/ono 0.1.1 → 0.1.3
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/package.json +18 -4
- package/src/browser/compiler.js +253 -0
- package/src/browser/unocss.js +29 -0
- package/src/builder.js +316 -0
- package/src/bundler.js +12 -4
- package/src/cli.js +178 -945
- package/src/content.js +272 -0
- package/src/jsx-runtime.js +19 -0
- package/src/renderer.js +11 -4
- package/src/server.js +99 -0
- package/src/unocss.js +28 -2
- package/src/watcher.js +167 -0
- package/README.md +0 -299
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
|
+
}
|
package/src/jsx-runtime.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|