@vojtaholik/static-kit-cli 2.0.0
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 +27 -0
- package/src/cli.ts +50 -0
- package/src/commands/build.ts +144 -0
- package/src/commands/dev.ts +401 -0
- package/src/commands/gen.ts +20 -0
- package/src/commands/sprite.ts +31 -0
- package/src/config-loader.ts +43 -0
- package/src/css-processor.ts +81 -0
- package/src/sprite-compiler.ts +75 -0
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vojtaholik/static-kit-cli",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"static-kit": "./src/cli.ts"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"src"
|
|
10
|
+
],
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "https://github.com/vojtaholik/module-kit",
|
|
14
|
+
"directory": "packages/cli"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@vojtaholik/static-kit-core": "^2.0.0",
|
|
21
|
+
"lightningcss": "^1.28.2",
|
|
22
|
+
"browserslist": "^4.24.4"
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"typescript": "^5"
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Static Block Kit CLI
|
|
4
|
+
*
|
|
5
|
+
* Commands:
|
|
6
|
+
* dev - Start development server
|
|
7
|
+
* build - Build static site for production
|
|
8
|
+
* gen - Compile block templates
|
|
9
|
+
* sprite - Compile SVGs into spritesheet
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const command = process.argv[2];
|
|
13
|
+
|
|
14
|
+
switch (command) {
|
|
15
|
+
case "dev":
|
|
16
|
+
await import("./commands/dev.ts");
|
|
17
|
+
break;
|
|
18
|
+
case "build":
|
|
19
|
+
await import("./commands/build.ts");
|
|
20
|
+
break;
|
|
21
|
+
case "gen":
|
|
22
|
+
await import("./commands/gen.ts");
|
|
23
|
+
break;
|
|
24
|
+
case "sprite":
|
|
25
|
+
await import("./commands/sprite.ts");
|
|
26
|
+
break;
|
|
27
|
+
case "--help":
|
|
28
|
+
case "-h":
|
|
29
|
+
case undefined:
|
|
30
|
+
console.log(`
|
|
31
|
+
Static Block Kit CLI
|
|
32
|
+
|
|
33
|
+
Usage:
|
|
34
|
+
static-block-kit <command>
|
|
35
|
+
|
|
36
|
+
Commands:
|
|
37
|
+
dev Start development server with hot reload
|
|
38
|
+
build Build static site for production
|
|
39
|
+
gen Compile block templates to render functions
|
|
40
|
+
sprite Compile SVGs from svg/ into spritesheet
|
|
41
|
+
|
|
42
|
+
Options:
|
|
43
|
+
--help, -h Show this help message
|
|
44
|
+
`);
|
|
45
|
+
break;
|
|
46
|
+
default:
|
|
47
|
+
console.error(`Unknown command: ${command}`);
|
|
48
|
+
console.error("Run 'static-block-kit --help' for available commands");
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Static Kit - Build Pipeline
|
|
4
|
+
*
|
|
5
|
+
* 1. Run template compiler (gen-blocks)
|
|
6
|
+
* 2. Load and validate all page configs
|
|
7
|
+
* 3. Render each page to HTML (no dev overlay)
|
|
8
|
+
* 4. Copy publicDir to dist/{publicPath}/ (1:1 structure)
|
|
9
|
+
* 5. Write HTML files to dist/ (flat structure)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { mkdir, rm, cp } from "node:fs/promises";
|
|
13
|
+
import { join, dirname } from "node:path";
|
|
14
|
+
import { Glob } from "bun";
|
|
15
|
+
import {
|
|
16
|
+
renderPage,
|
|
17
|
+
compileBlockTemplates,
|
|
18
|
+
type PageConfig,
|
|
19
|
+
} from "@vojtaholik/static-kit-core";
|
|
20
|
+
import { loadConfig, resolvePath } from "../config-loader.ts";
|
|
21
|
+
import { processCSS } from "../css-processor.ts";
|
|
22
|
+
import { compileSpritesheet } from "../sprite-compiler.ts";
|
|
23
|
+
|
|
24
|
+
const cwd = process.cwd();
|
|
25
|
+
const config = await loadConfig(cwd);
|
|
26
|
+
|
|
27
|
+
const blocksDir = resolvePath(config, "blocksDir", cwd);
|
|
28
|
+
const pagesDir = resolvePath(config, "pagesDir", cwd);
|
|
29
|
+
const publicDir = resolvePath(config, "publicDir", cwd);
|
|
30
|
+
const outDir = resolvePath(config, "outDir", cwd);
|
|
31
|
+
|
|
32
|
+
// Strip leading slash from publicPath for filesystem operations
|
|
33
|
+
const publicPathDir = config.publicPath.replace(/^\//, "");
|
|
34
|
+
|
|
35
|
+
async function build() {
|
|
36
|
+
console.log("🔨 Building static site...\n");
|
|
37
|
+
|
|
38
|
+
// Clean dist directory
|
|
39
|
+
console.log("Cleaning output directory...");
|
|
40
|
+
await rm(outDir, { recursive: true, force: true });
|
|
41
|
+
await mkdir(outDir, { recursive: true });
|
|
42
|
+
|
|
43
|
+
// Step 1: Compile templates
|
|
44
|
+
console.log("\n📝 Compiling block templates...");
|
|
45
|
+
await compileBlockTemplates({
|
|
46
|
+
blocksDir,
|
|
47
|
+
genDir: join(blocksDir, "gen"),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Step 1.5: Compile SVG spritesheet
|
|
51
|
+
console.log("\n🎨 Compiling SVG spritesheet...");
|
|
52
|
+
try {
|
|
53
|
+
const { count } = await compileSpritesheet({ publicDir });
|
|
54
|
+
console.log(` ✓ ${count} SVGs compiled`);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
57
|
+
throw err;
|
|
58
|
+
}
|
|
59
|
+
console.log(" ℹ No svg/ directory found, skipping");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Step 2: Import fresh modules (after template compilation)
|
|
63
|
+
const blocksModule = await import(join(blocksDir, "index.ts"));
|
|
64
|
+
const pagesModule = await import(join(pagesDir, "index.ts"));
|
|
65
|
+
|
|
66
|
+
// Register blocks
|
|
67
|
+
if (typeof blocksModule.registerAllBlocks === "function") {
|
|
68
|
+
blocksModule.registerAllBlocks();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const pages: PageConfig[] = pagesModule.pages;
|
|
72
|
+
|
|
73
|
+
// Step 3: Render all pages (flat structure in dist/)
|
|
74
|
+
console.log("\n📄 Rendering pages...");
|
|
75
|
+
for (const page of pages) {
|
|
76
|
+
const html = await renderPage(page, {
|
|
77
|
+
templateDir: pagesDir,
|
|
78
|
+
isDev: false,
|
|
79
|
+
assetBase: "/",
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Flat output: /about -> dist/about.html, / -> dist/index.html
|
|
83
|
+
const fileName =
|
|
84
|
+
page.path === "/" ? "index.html" : `${page.path.replace(/^\//, "")}.html`;
|
|
85
|
+
const outPath = join(outDir, fileName);
|
|
86
|
+
|
|
87
|
+
// Write HTML
|
|
88
|
+
await Bun.write(outPath, html);
|
|
89
|
+
console.log(` ✓ ${page.path} → ${outPath}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Step 4: Copy publicDir to dist/public/ (1:1 mirrored structure)
|
|
93
|
+
console.log("\n📦 Copying public assets...");
|
|
94
|
+
const outPublicDir = join(outDir, publicPathDir);
|
|
95
|
+
|
|
96
|
+
const publicGlob = new Glob("**/*");
|
|
97
|
+
for await (const file of publicGlob.scan(publicDir)) {
|
|
98
|
+
const srcFile = join(publicDir, file);
|
|
99
|
+
const destFile = join(outPublicDir, file);
|
|
100
|
+
|
|
101
|
+
const bunFile = Bun.file(srcFile);
|
|
102
|
+
if (await bunFile.exists()) {
|
|
103
|
+
await mkdir(dirname(destFile), { recursive: true });
|
|
104
|
+
|
|
105
|
+
// Process CSS files through lightningcss
|
|
106
|
+
if (file.endsWith(".css")) {
|
|
107
|
+
const cssBytes = new Uint8Array(await bunFile.arrayBuffer());
|
|
108
|
+
const result = processCSS({
|
|
109
|
+
filename: srcFile,
|
|
110
|
+
code: cssBytes,
|
|
111
|
+
minify: true,
|
|
112
|
+
});
|
|
113
|
+
await Bun.write(destFile, result.code);
|
|
114
|
+
console.log(` ✓ ${config.publicPath}/${file} (minified)`);
|
|
115
|
+
} else {
|
|
116
|
+
await Bun.write(destFile, await bunFile.arrayBuffer());
|
|
117
|
+
console.log(` ✓ ${config.publicPath}/${file}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Done
|
|
123
|
+
console.log("\n✅ Build complete!");
|
|
124
|
+
console.log(` Output: ${outDir}/`);
|
|
125
|
+
|
|
126
|
+
// List output files
|
|
127
|
+
console.log("\n Files:");
|
|
128
|
+
const glob = new Glob("**/*");
|
|
129
|
+
for await (const file of glob.scan(outDir)) {
|
|
130
|
+
const stat = await Bun.file(join(outDir, file)).size;
|
|
131
|
+
console.log(` - ${file} (${formatBytes(stat)})`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function formatBytes(bytes: number): string {
|
|
136
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
137
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
138
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
build().catch((err) => {
|
|
142
|
+
console.error("Build failed:", err);
|
|
143
|
+
process.exit(1);
|
|
144
|
+
});
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Static Kit - Development Server
|
|
4
|
+
*
|
|
5
|
+
* Bun.serve() based dev server with:
|
|
6
|
+
* - Page routes (rendered HTML with dev overlay)
|
|
7
|
+
* - Static asset serving
|
|
8
|
+
* - Dev API endpoints
|
|
9
|
+
* - Hot reload via SSE
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { watch } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import {
|
|
15
|
+
decodeSchemaAddress,
|
|
16
|
+
blockRegistry,
|
|
17
|
+
renderPage,
|
|
18
|
+
compileBlockTemplates,
|
|
19
|
+
type PageConfig,
|
|
20
|
+
} from "@vojtaholik/static-kit-core";
|
|
21
|
+
import { loadConfig, resolvePath } from "../config-loader.ts";
|
|
22
|
+
import { processCSS } from "../css-processor.ts";
|
|
23
|
+
import { compileSpritesheet } from "../sprite-compiler.ts";
|
|
24
|
+
|
|
25
|
+
const cwd = process.cwd();
|
|
26
|
+
const config = await loadConfig(cwd);
|
|
27
|
+
|
|
28
|
+
const blocksDir = resolvePath(config, "blocksDir", cwd);
|
|
29
|
+
const pagesDir = resolvePath(config, "pagesDir", cwd);
|
|
30
|
+
const publicDir = resolvePath(config, "publicDir", cwd);
|
|
31
|
+
const svgDir = join(publicDir, "svg");
|
|
32
|
+
|
|
33
|
+
// Compile block templates before importing blocks (gen/ may not exist yet)
|
|
34
|
+
console.log("🔨 Compiling block templates...");
|
|
35
|
+
await compileBlockTemplates({
|
|
36
|
+
blocksDir,
|
|
37
|
+
genDir: join(blocksDir, "gen"),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Compile SVG spritesheet if svg/ directory exists
|
|
41
|
+
try {
|
|
42
|
+
console.log("🎨 Compiling SVG spritesheet...");
|
|
43
|
+
const { count } = await compileSpritesheet({ publicDir });
|
|
44
|
+
if (count === 0) {
|
|
45
|
+
console.log(" (no SVGs found in svg/)");
|
|
46
|
+
}
|
|
47
|
+
} catch (err) {
|
|
48
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
49
|
+
console.warn(" ⚠ Sprite compilation failed:", err);
|
|
50
|
+
}
|
|
51
|
+
// svg/ directory doesn't exist, skip silently
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Dynamically import site's blocks and pages
|
|
55
|
+
const blocksModule = await import(join(blocksDir, "index.ts"));
|
|
56
|
+
const pagesModule = await import(join(pagesDir, "index.ts"));
|
|
57
|
+
|
|
58
|
+
// Register all blocks at startup
|
|
59
|
+
if (typeof blocksModule.registerAllBlocks === "function") {
|
|
60
|
+
blocksModule.registerAllBlocks();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const pages: PageConfig[] = pagesModule.pages;
|
|
64
|
+
const getPageByPath = pagesModule.getPageByPath as (
|
|
65
|
+
path: string
|
|
66
|
+
) => PageConfig | undefined;
|
|
67
|
+
|
|
68
|
+
// Try to load cms-blocks if it exists
|
|
69
|
+
let cmsBlocks: Record<string, unknown> = {};
|
|
70
|
+
try {
|
|
71
|
+
const cmsPath = config.cmsBlocksFile
|
|
72
|
+
? join(cwd, config.cmsBlocksFile)
|
|
73
|
+
: join(cwd, "cms-blocks.ts");
|
|
74
|
+
const cmsBlocksModule = await import(`file://${cmsPath}`);
|
|
75
|
+
cmsBlocks = cmsBlocksModule.cmsBlocks ?? cmsBlocksModule.default ?? {};
|
|
76
|
+
} catch {
|
|
77
|
+
// No cms-blocks file, that's fine
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const PORT = config.devPort;
|
|
81
|
+
const publicPath = config.publicPath; // e.g. "/public"
|
|
82
|
+
|
|
83
|
+
// Server start time for HMR restart detection
|
|
84
|
+
const SERVER_START_TIME = Date.now();
|
|
85
|
+
|
|
86
|
+
// SSE clients for hot reload
|
|
87
|
+
const sseClients = new Set<ReadableStreamDefaultController<Uint8Array>>();
|
|
88
|
+
const sseHeartbeats = new Map<
|
|
89
|
+
ReadableStreamDefaultController<Uint8Array>,
|
|
90
|
+
ReturnType<typeof setInterval>
|
|
91
|
+
>();
|
|
92
|
+
|
|
93
|
+
// Debounce file change notifications
|
|
94
|
+
let reloadTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
95
|
+
let lastChangeType: "full" | "css" = "full";
|
|
96
|
+
|
|
97
|
+
function broadcastReload(type: "full" | "css" = "full") {
|
|
98
|
+
// Debounce rapid changes
|
|
99
|
+
if (reloadTimeout) {
|
|
100
|
+
clearTimeout(reloadTimeout);
|
|
101
|
+
// Escalate to full reload if mixed changes
|
|
102
|
+
if (type === "full") lastChangeType = "full";
|
|
103
|
+
} else {
|
|
104
|
+
lastChangeType = type;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
reloadTimeout = setTimeout(() => {
|
|
108
|
+
reloadTimeout = null;
|
|
109
|
+
const message = `data: ${JSON.stringify({
|
|
110
|
+
type: lastChangeType,
|
|
111
|
+
time: Date.now(),
|
|
112
|
+
})}\n\n`;
|
|
113
|
+
const encoder = new TextEncoder();
|
|
114
|
+
const data = encoder.encode(message);
|
|
115
|
+
|
|
116
|
+
for (const controller of sseClients) {
|
|
117
|
+
try {
|
|
118
|
+
controller.enqueue(data);
|
|
119
|
+
} catch {
|
|
120
|
+
// Clean up both client and heartbeat when controller fails
|
|
121
|
+
const heartbeat = sseHeartbeats.get(controller);
|
|
122
|
+
if (heartbeat) {
|
|
123
|
+
clearInterval(heartbeat);
|
|
124
|
+
sseHeartbeats.delete(controller);
|
|
125
|
+
}
|
|
126
|
+
sseClients.delete(controller);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
console.log(
|
|
130
|
+
`🔄 ${lastChangeType === "css" ? "CSS" : "Full"} reload triggered`
|
|
131
|
+
);
|
|
132
|
+
}, 50);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Watch for file changes
|
|
136
|
+
const watchDirs = [blocksDir, pagesDir, publicDir, svgDir];
|
|
137
|
+
|
|
138
|
+
async function handleFileChange(filename: string, dir: string) {
|
|
139
|
+
// Ignore generated files
|
|
140
|
+
if (filename.includes("/gen/") || filename.endsWith(".render.ts")) return;
|
|
141
|
+
|
|
142
|
+
// SVG in svg/ directory changed - recompile spritesheet
|
|
143
|
+
if (filename.endsWith(".svg") && dir === svgDir) {
|
|
144
|
+
console.log(`🎨 SVG changed: ${filename}`);
|
|
145
|
+
try {
|
|
146
|
+
await compileSpritesheet({ publicDir });
|
|
147
|
+
broadcastReload("css"); // No full reload needed for SVG sprites
|
|
148
|
+
} catch (err) {
|
|
149
|
+
console.warn(" ⚠ Sprite compilation failed:", err);
|
|
150
|
+
}
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (filename.endsWith(".css")) {
|
|
155
|
+
broadcastReload("css");
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// HTML template changed - recompile templates before reload
|
|
160
|
+
if (filename.endsWith(".block.html")) {
|
|
161
|
+
console.log(`📝 Template changed: ${filename}`);
|
|
162
|
+
await compileBlockTemplates({
|
|
163
|
+
blocksDir,
|
|
164
|
+
genDir: join(blocksDir, "gen"),
|
|
165
|
+
});
|
|
166
|
+
// bun --watch will restart the server after recompile
|
|
167
|
+
// which triggers browser reload via SSE reconnect
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// For other source files, just broadcast (bun --watch handles the restart)
|
|
172
|
+
if (filename.endsWith(".ts") || filename.endsWith(".js")) {
|
|
173
|
+
broadcastReload("full");
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
for (const dir of watchDirs) {
|
|
178
|
+
try {
|
|
179
|
+
watch(dir, { recursive: true }, (event, filename) => {
|
|
180
|
+
if (!filename) return;
|
|
181
|
+
handleFileChange(filename, dir);
|
|
182
|
+
});
|
|
183
|
+
} catch {
|
|
184
|
+
// Directory might not exist yet
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
console.log(`🚀 Starting dev server on http://localhost:${PORT}`);
|
|
189
|
+
console.log(`👀 Watching for changes...`);
|
|
190
|
+
|
|
191
|
+
Bun.serve({
|
|
192
|
+
port: PORT,
|
|
193
|
+
idleTimeout: 120, // Don't kill SSE connections every 10s
|
|
194
|
+
async fetch(req) {
|
|
195
|
+
const url = new URL(req.url);
|
|
196
|
+
const path = url.pathname;
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
// Dev overlay (special internal asset)
|
|
200
|
+
if (path === "/__dev-overlay.js") {
|
|
201
|
+
const js = await Bun.file(join(publicDir, "js/dev-overlay.js")).text();
|
|
202
|
+
return new Response(js, {
|
|
203
|
+
headers: { "Content-Type": "application/javascript" },
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Hot reload SSE endpoint
|
|
208
|
+
if (path === "/__hmr") {
|
|
209
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
210
|
+
start(controller) {
|
|
211
|
+
sseClients.add(controller);
|
|
212
|
+
const encoder = new TextEncoder();
|
|
213
|
+
// Send initial connected message with server start time
|
|
214
|
+
controller.enqueue(
|
|
215
|
+
encoder.encode(
|
|
216
|
+
`data: ${JSON.stringify({
|
|
217
|
+
type: "connected",
|
|
218
|
+
time: SERVER_START_TIME,
|
|
219
|
+
})}\n\n`
|
|
220
|
+
)
|
|
221
|
+
);
|
|
222
|
+
// Heartbeat every 30s to keep connection alive
|
|
223
|
+
const heartbeat = setInterval(() => {
|
|
224
|
+
try {
|
|
225
|
+
controller.enqueue(encoder.encode(": heartbeat\n\n"));
|
|
226
|
+
} catch {
|
|
227
|
+
clearInterval(heartbeat);
|
|
228
|
+
sseHeartbeats.delete(controller);
|
|
229
|
+
}
|
|
230
|
+
}, 30000);
|
|
231
|
+
sseHeartbeats.set(controller, heartbeat);
|
|
232
|
+
},
|
|
233
|
+
cancel(controller) {
|
|
234
|
+
const heartbeat = sseHeartbeats.get(controller);
|
|
235
|
+
if (heartbeat) {
|
|
236
|
+
clearInterval(heartbeat);
|
|
237
|
+
sseHeartbeats.delete(controller);
|
|
238
|
+
}
|
|
239
|
+
sseClients.delete(controller);
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
return new Response(stream, {
|
|
244
|
+
headers: {
|
|
245
|
+
"Content-Type": "text/event-stream",
|
|
246
|
+
"Cache-Control": "no-cache",
|
|
247
|
+
Connection: "keep-alive",
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Dev API endpoints
|
|
253
|
+
if (path === "/__pages") {
|
|
254
|
+
return Response.json(
|
|
255
|
+
pages.map((p) => ({
|
|
256
|
+
id: p.id,
|
|
257
|
+
path: p.path,
|
|
258
|
+
title: p.title,
|
|
259
|
+
}))
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (path === "/__site") {
|
|
264
|
+
return Response.json({
|
|
265
|
+
pages: pages.map((p) => ({
|
|
266
|
+
id: p.id,
|
|
267
|
+
path: p.path,
|
|
268
|
+
title: p.title,
|
|
269
|
+
})),
|
|
270
|
+
blocks: blockRegistry.types(),
|
|
271
|
+
schemas: cmsBlocks,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (path === "/__inspect") {
|
|
276
|
+
const address = url.searchParams.get("address");
|
|
277
|
+
if (!address) {
|
|
278
|
+
return Response.json(
|
|
279
|
+
{ error: "Missing address parameter" },
|
|
280
|
+
{ status: 400 }
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
const decoded = decodeSchemaAddress(address);
|
|
286
|
+
const page = pages.find((p) => p.id === decoded.pageId);
|
|
287
|
+
const region = page?.regions[decoded.region];
|
|
288
|
+
const block = region?.blocks.find((b) => b.id === decoded.blockId);
|
|
289
|
+
const blockDef = block ? blockRegistry.get(block.type) : null;
|
|
290
|
+
|
|
291
|
+
// Convert file:// URL to absolute path if needed
|
|
292
|
+
const sourceFile = blockDef?.sourceFile?.startsWith("file://")
|
|
293
|
+
? blockDef.sourceFile.replace("file://", "")
|
|
294
|
+
: blockDef?.sourceFile;
|
|
295
|
+
|
|
296
|
+
return Response.json({
|
|
297
|
+
address: decoded,
|
|
298
|
+
page: page
|
|
299
|
+
? {
|
|
300
|
+
id: page.id,
|
|
301
|
+
path: page.path,
|
|
302
|
+
title: page.title,
|
|
303
|
+
sourceFile: `${pagesDir}/${decoded.pageId}.page.ts`,
|
|
304
|
+
}
|
|
305
|
+
: null,
|
|
306
|
+
block: block
|
|
307
|
+
? {
|
|
308
|
+
id: block.id,
|
|
309
|
+
type: block.type,
|
|
310
|
+
props: block.props,
|
|
311
|
+
sourceFile,
|
|
312
|
+
templateFile: `${blocksDir}/${block.type}.block.html`,
|
|
313
|
+
}
|
|
314
|
+
: null,
|
|
315
|
+
schema: block ? cmsBlocks[block.type] : null,
|
|
316
|
+
});
|
|
317
|
+
} catch (err) {
|
|
318
|
+
return Response.json(
|
|
319
|
+
{ error: "Invalid address", details: String(err) },
|
|
320
|
+
{ status: 400 }
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Public static files - serve publicDir at publicPath (1:1 mapping)
|
|
326
|
+
if (path.startsWith(`${publicPath}/`)) {
|
|
327
|
+
const relativePath = path.slice(publicPath.length + 1);
|
|
328
|
+
const filePath = join(publicDir, relativePath);
|
|
329
|
+
const file = Bun.file(filePath);
|
|
330
|
+
if (await file.exists()) {
|
|
331
|
+
// Process CSS through lightningcss (no minification in dev)
|
|
332
|
+
if (filePath.endsWith(".css")) {
|
|
333
|
+
const cssBytes = new Uint8Array(await file.arrayBuffer());
|
|
334
|
+
const result = processCSS({
|
|
335
|
+
filename: filePath,
|
|
336
|
+
code: cssBytes,
|
|
337
|
+
minify: false,
|
|
338
|
+
});
|
|
339
|
+
return new Response(new TextDecoder().decode(result.code), {
|
|
340
|
+
headers: {
|
|
341
|
+
"Content-Type": "text/css",
|
|
342
|
+
"Cache-Control": "no-cache, no-store, must-revalidate",
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
return new Response(file, {
|
|
347
|
+
headers: { "Cache-Control": "no-cache, no-store, must-revalidate" },
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Page routes
|
|
353
|
+
const pagePath = path === "/" ? "/" : path.replace(/\/$/, "");
|
|
354
|
+
const page = getPageByPath(pagePath);
|
|
355
|
+
|
|
356
|
+
if (page) {
|
|
357
|
+
const html = await renderPage(page, {
|
|
358
|
+
templateDir: pagesDir,
|
|
359
|
+
isDev: true,
|
|
360
|
+
assetBase: "/",
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
return new Response(html, {
|
|
364
|
+
headers: { "Content-Type": "text/html" },
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// 404
|
|
369
|
+
return new Response("Not Found", { status: 404 });
|
|
370
|
+
} catch (err) {
|
|
371
|
+
console.error("Error handling request:", err);
|
|
372
|
+
return new Response(
|
|
373
|
+
`<html>
|
|
374
|
+
<head><title>Error</title></head>
|
|
375
|
+
<body style="font-family: system-ui; padding: 2rem;">
|
|
376
|
+
<h1>Server Error</h1>
|
|
377
|
+
<pre style="background: #f5f5f5; padding: 1rem; overflow: auto;">${String(
|
|
378
|
+
err
|
|
379
|
+
)}</pre>
|
|
380
|
+
</body>
|
|
381
|
+
</html>`,
|
|
382
|
+
{
|
|
383
|
+
status: 500,
|
|
384
|
+
headers: { "Content-Type": "text/html" },
|
|
385
|
+
}
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
console.log(`
|
|
392
|
+
Dev server running at http://localhost:${PORT}
|
|
393
|
+
|
|
394
|
+
Pages: ${pagesDir}
|
|
395
|
+
Public: ${publicDir} → ${publicPath}/
|
|
396
|
+
|
|
397
|
+
Dev API:
|
|
398
|
+
/__pages → List all pages
|
|
399
|
+
/__site → Site config (pages, blocks, schemas)
|
|
400
|
+
/__inspect → Decode schema address
|
|
401
|
+
`);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Generate block render functions from templates
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { compileBlockTemplates } from "@vojtaholik/static-kit-core";
|
|
8
|
+
import { loadConfig } from "../config-loader.ts";
|
|
9
|
+
|
|
10
|
+
const cwd = process.cwd();
|
|
11
|
+
const config = await loadConfig(cwd);
|
|
12
|
+
|
|
13
|
+
console.log("🔨 Compiling block templates...");
|
|
14
|
+
|
|
15
|
+
await compileBlockTemplates({
|
|
16
|
+
blocksDir: join(cwd, config.blocksDir),
|
|
17
|
+
genDir: join(cwd, config.blocksDir, "gen"),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
console.log("✅ Done!");
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Static Kit - SVG Sprite CLI Command
|
|
4
|
+
*
|
|
5
|
+
* Compiles individual SVG files from publicDir/svg/ into a single spritesheet.
|
|
6
|
+
*
|
|
7
|
+
* Usage in HTML:
|
|
8
|
+
* <svg><use href="/public/sprite.svg#icon-name"/></svg>
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { loadConfig, resolvePath } from "../config-loader.ts";
|
|
12
|
+
import { compileSpritesheet } from "../sprite-compiler.ts";
|
|
13
|
+
|
|
14
|
+
const cwd = process.cwd();
|
|
15
|
+
const config = await loadConfig(cwd);
|
|
16
|
+
const publicDir = resolvePath(config, "publicDir", cwd);
|
|
17
|
+
|
|
18
|
+
console.log("🎨 Compiling SVG spritesheet...\n");
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const { count, outputPath } = await compileSpritesheet({ publicDir });
|
|
22
|
+
console.log(` ✓ Compiled ${count} SVGs → ${outputPath}`);
|
|
23
|
+
console.log("\n✅ Sprite compilation complete!");
|
|
24
|
+
} catch (err) {
|
|
25
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
26
|
+
console.log(" ℹ No svg/ directory found, skipping sprite compilation");
|
|
27
|
+
} else {
|
|
28
|
+
console.error("Sprite compilation failed:", err);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { configSchema, type StaticKitConfig } from "@vojtaholik/static-kit-core";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Load static-kit.config.ts from the current working directory
|
|
7
|
+
*/
|
|
8
|
+
export async function loadConfig(
|
|
9
|
+
cwd = process.cwd()
|
|
10
|
+
): Promise<StaticKitConfig> {
|
|
11
|
+
const configPath = join(cwd, "static-kit.config.ts");
|
|
12
|
+
|
|
13
|
+
// Check if file exists first
|
|
14
|
+
if (!existsSync(configPath)) {
|
|
15
|
+
console.log("No static-kit.config.ts found, using defaults");
|
|
16
|
+
return configSchema.parse({});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
// Use file:// URL for dynamic imports
|
|
21
|
+
const configModule = await import(`file://${configPath}`);
|
|
22
|
+
const rawConfig = configModule.default ?? configModule;
|
|
23
|
+
return configSchema.parse(rawConfig);
|
|
24
|
+
} catch (err) {
|
|
25
|
+
console.error("Error loading config:", err);
|
|
26
|
+
throw err;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve a path relative to the project root
|
|
32
|
+
*/
|
|
33
|
+
export function resolvePath(
|
|
34
|
+
config: StaticKitConfig,
|
|
35
|
+
key: keyof StaticKitConfig,
|
|
36
|
+
cwd = process.cwd()
|
|
37
|
+
): string {
|
|
38
|
+
const value = config[key];
|
|
39
|
+
if (typeof value !== "string") {
|
|
40
|
+
throw new Error(`Config key ${key} is not a string`);
|
|
41
|
+
}
|
|
42
|
+
return join(cwd, value);
|
|
43
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSS Processing with lightningcss
|
|
3
|
+
*
|
|
4
|
+
* Handles:
|
|
5
|
+
* - CSS nesting (native)
|
|
6
|
+
* - Custom media queries
|
|
7
|
+
* - Vendor prefixes (autoprefixer)
|
|
8
|
+
* - Minification (build only)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { transform, browserslistToTargets } from "lightningcss";
|
|
12
|
+
import browserslist from "browserslist";
|
|
13
|
+
|
|
14
|
+
// Cache the targets since browserslist resolution is expensive
|
|
15
|
+
let cachedTargets: ReturnType<typeof browserslistToTargets> | null = null;
|
|
16
|
+
|
|
17
|
+
function getTargets() {
|
|
18
|
+
if (!cachedTargets) {
|
|
19
|
+
cachedTargets = browserslistToTargets(browserslist("> 0.5%, not dead"));
|
|
20
|
+
}
|
|
21
|
+
return cachedTargets;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CSSProcessOptions {
|
|
25
|
+
/** Enable minification (typically false for dev, true for build) */
|
|
26
|
+
minify?: boolean;
|
|
27
|
+
/** Filename for error reporting and source maps */
|
|
28
|
+
filename: string;
|
|
29
|
+
/** Raw CSS content as Uint8Array */
|
|
30
|
+
code: Uint8Array;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface CSSProcessResult {
|
|
34
|
+
code: Uint8Array;
|
|
35
|
+
map: Uint8Array | undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Process CSS through lightningcss
|
|
40
|
+
*
|
|
41
|
+
* Features enabled:
|
|
42
|
+
* - CSS nesting (lowered for browser compat)
|
|
43
|
+
* - Custom media queries draft spec
|
|
44
|
+
* - Vendor prefixes based on browserslist targets
|
|
45
|
+
*/
|
|
46
|
+
export function processCSS(options: CSSProcessOptions): CSSProcessResult {
|
|
47
|
+
const result = transform({
|
|
48
|
+
filename: options.filename,
|
|
49
|
+
code: options.code,
|
|
50
|
+
minify: options.minify ?? false,
|
|
51
|
+
targets: getTargets(),
|
|
52
|
+
drafts: {
|
|
53
|
+
customMedia: true,
|
|
54
|
+
},
|
|
55
|
+
// Error on unknown at-rules instead of passing through
|
|
56
|
+
errorRecovery: false,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
code: result.code,
|
|
61
|
+
map: result.map ?? undefined,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Process CSS from a string (convenience wrapper)
|
|
67
|
+
*/
|
|
68
|
+
export function processCSSString(
|
|
69
|
+
css: string,
|
|
70
|
+
options: Omit<CSSProcessOptions, "code">
|
|
71
|
+
): string {
|
|
72
|
+
const encoder = new TextEncoder();
|
|
73
|
+
const decoder = new TextDecoder();
|
|
74
|
+
|
|
75
|
+
const result = processCSS({
|
|
76
|
+
...options,
|
|
77
|
+
code: encoder.encode(css),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
return decoder.decode(result.code);
|
|
81
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SVG Sprite Compiler
|
|
3
|
+
*
|
|
4
|
+
* Compiles individual SVG files into a single spritesheet.
|
|
5
|
+
* Each SVG becomes a <symbol> element with id matching the filename.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { join, basename } from "node:path";
|
|
9
|
+
import { Glob } from "bun";
|
|
10
|
+
|
|
11
|
+
export interface SpriteCompileOptions {
|
|
12
|
+
publicDir: string;
|
|
13
|
+
/** Relative path within publicDir for source SVGs */
|
|
14
|
+
svgDir?: string;
|
|
15
|
+
/** Output filename within publicDir */
|
|
16
|
+
outputFile?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Parse an SVG string and extract viewBox + inner content
|
|
21
|
+
*/
|
|
22
|
+
function parseSvg(content: string): { viewBox: string; inner: string } | null {
|
|
23
|
+
// Extract viewBox attribute
|
|
24
|
+
const viewBoxMatch = content.match(/viewBox=["']([^"']+)["']/);
|
|
25
|
+
const viewBox = viewBoxMatch?.[1] ?? "0 0 24 24";
|
|
26
|
+
|
|
27
|
+
// Extract content between <svg> tags
|
|
28
|
+
const svgMatch = content.match(/<svg[^>]*>([\s\S]*)<\/svg>/i);
|
|
29
|
+
if (!svgMatch) return null;
|
|
30
|
+
|
|
31
|
+
const inner = svgMatch[1]?.trim() ?? "";
|
|
32
|
+
return { viewBox, inner };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Compile SVGs from a directory into a spritesheet
|
|
37
|
+
*/
|
|
38
|
+
export async function compileSpritesheet(
|
|
39
|
+
options: SpriteCompileOptions
|
|
40
|
+
): Promise<{ count: number; outputPath: string }> {
|
|
41
|
+
const { publicDir, svgDir = "svg", outputFile = "sprite.svg" } = options;
|
|
42
|
+
|
|
43
|
+
const inputDir = join(publicDir, svgDir);
|
|
44
|
+
const outputPath = join(publicDir, outputFile);
|
|
45
|
+
|
|
46
|
+
const symbols: string[] = [];
|
|
47
|
+
const glob = new Glob("*.svg");
|
|
48
|
+
|
|
49
|
+
for await (const file of glob.scan(inputDir)) {
|
|
50
|
+
const filePath = join(inputDir, file);
|
|
51
|
+
const content = await Bun.file(filePath).text();
|
|
52
|
+
|
|
53
|
+
const parsed = parseSvg(content);
|
|
54
|
+
if (!parsed) {
|
|
55
|
+
console.warn(` ⚠ Skipping ${file}: could not parse SVG`);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Use filename without extension as symbol id
|
|
60
|
+
const id = basename(file, ".svg");
|
|
61
|
+
symbols.push(
|
|
62
|
+
` <symbol id="${id}" viewBox="${parsed.viewBox}">\n ${parsed.inner}\n </symbol>`
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Generate spritesheet
|
|
67
|
+
const spritesheet = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="display:none;">
|
|
68
|
+
${symbols.join("\n")}
|
|
69
|
+
</svg>
|
|
70
|
+
`;
|
|
71
|
+
|
|
72
|
+
await Bun.write(outputPath, spritesheet);
|
|
73
|
+
|
|
74
|
+
return { count: symbols.length, outputPath };
|
|
75
|
+
}
|