basecampjs 0.0.1

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.
Files changed (2) hide show
  1. package/index.js +346 -0
  2. package/package.json +24 -0
package/index.js ADDED
@@ -0,0 +1,346 @@
1
+ #!/usr/bin/env node
2
+ import { argv, exit } from "process";
3
+ import { createServer } from "http";
4
+ import { existsSync } from "fs";
5
+ import { cp, mkdir, readFile, readdir, rm, stat, writeFile } from "fs/promises";
6
+ import { basename, dirname, extname, join, relative, resolve } from "path";
7
+ import { pathToFileURL } from "url";
8
+ import * as kolor from "kolorist";
9
+ import chokidar from "chokidar";
10
+ import matter from "gray-matter";
11
+ import MarkdownIt from "markdown-it";
12
+ import nunjucks from "nunjucks";
13
+ import { Liquid } from "liquidjs";
14
+
15
+ const cwd = process.cwd();
16
+ const md = new MarkdownIt({ html: true, linkify: true, typographer: true });
17
+
18
+ const defaultConfig = {
19
+ siteName: "Campsite",
20
+ srcDir: "src",
21
+ outDir: "dist",
22
+ templateEngine: "nunjucks",
23
+ markdown: true,
24
+ integrations: { nunjucks: true, liquid: false, vue: false, alpine: false }
25
+ };
26
+
27
+ async function loadConfig(root) {
28
+ const configPath = join(root, "campsite.config.js");
29
+ if (!existsSync(configPath)) return { ...defaultConfig };
30
+ try {
31
+ const imported = await import(pathToFileURL(configPath));
32
+ const user = imported.default || imported;
33
+ return { ...defaultConfig, ...user };
34
+ } catch (err) {
35
+ console.error(kolor.red(`Failed to load config: ${err.message}`));
36
+ return { ...defaultConfig };
37
+ }
38
+ }
39
+
40
+ async function ensureDir(dir) {
41
+ await mkdir(dir, { recursive: true });
42
+ }
43
+
44
+ async function loadData(dataDir) {
45
+ const collections = {};
46
+ if (!existsSync(dataDir)) return collections;
47
+ const files = await walkFiles(dataDir);
48
+ for (const file of files) {
49
+ if (extname(file).toLowerCase() !== ".json") continue;
50
+ const name = basename(file, ".json");
51
+ try {
52
+ const raw = await readFile(file, "utf8");
53
+ collections[name] = JSON.parse(raw);
54
+ } catch (err) {
55
+ console.error(kolor.red(`Failed to load data ${relative(dataDir, file)}: ${err.message}`));
56
+ }
57
+ }
58
+ return collections;
59
+ }
60
+
61
+ async function cleanDir(dir) {
62
+ await rm(dir, { recursive: true, force: true });
63
+ await mkdir(dir, { recursive: true });
64
+ }
65
+
66
+ async function copyPublic(publicDir, outDir) {
67
+ if (existsSync(publicDir)) {
68
+ await cp(publicDir, outDir, { recursive: true });
69
+ }
70
+ }
71
+
72
+ async function walkFiles(dir) {
73
+ const results = [];
74
+ if (!existsSync(dir)) return results;
75
+ const entries = await readdir(dir, { withFileTypes: true });
76
+ for (const entry of entries) {
77
+ if (entry.name.startsWith(".")) continue;
78
+ const full = join(dir, entry.name);
79
+ if (entry.isDirectory()) {
80
+ results.push(...await walkFiles(full));
81
+ } else {
82
+ results.push(full);
83
+ }
84
+ }
85
+ return results;
86
+ }
87
+
88
+ function createNunjucksEnv(layoutsDir, pagesDir, srcDir) {
89
+ // Allow templates to resolve from layouts, pages, or the src root
90
+ return new nunjucks.Environment(
91
+ new nunjucks.FileSystemLoader([layoutsDir, pagesDir, srcDir], { noCache: true }),
92
+ { autoescape: false }
93
+ );
94
+ }
95
+
96
+ function createLiquidEnv(layoutsDir, pagesDir, srcDir) {
97
+ // Liquid loader will search these roots for partials/layouts
98
+ return new Liquid({
99
+ root: [layoutsDir, pagesDir, srcDir],
100
+ extname: ".liquid",
101
+ cache: false
102
+ });
103
+ }
104
+
105
+ function pageContext(frontmatter, html, config, relPath, data) {
106
+ return {
107
+ site: { name: config.siteName, config },
108
+ page: { ...frontmatter, content: html, source: relPath },
109
+ frontmatter,
110
+ content: html,
111
+ data,
112
+ collections: data,
113
+ ...data
114
+ };
115
+ }
116
+
117
+ function shouldRenderMarkdown(frontmatter, config, defaultValue) {
118
+ if (typeof frontmatter?.markdown === "boolean") return frontmatter.markdown;
119
+ return defaultValue;
120
+ }
121
+
122
+ async function renderWithLayout(layoutName, html, ctx, env, liquidEnv) {
123
+ if (!layoutName) return html;
124
+ const ext = extname(layoutName).toLowerCase();
125
+ const layoutCtx = {
126
+ ...ctx,
127
+ content: html,
128
+ title: ctx.frontmatter?.title ?? ctx.page?.title ?? ctx.site?.name
129
+ };
130
+
131
+ if (ext === ".njk") {
132
+ return env.render(layoutName, layoutCtx);
133
+ }
134
+
135
+ if (ext === ".liquid" || layoutName.toLowerCase().endsWith(".liquid.html")) {
136
+ return liquidEnv.renderFile(layoutName, layoutCtx);
137
+ }
138
+
139
+ // Unknown layout type, return unwrapped content
140
+ return html;
141
+ }
142
+
143
+ async function renderPage(filePath, { pagesDir, layoutsDir, outDir, env, liquidEnv, config, data }) {
144
+ const rel = relative(pagesDir, filePath);
145
+ const ext = extname(filePath).toLowerCase();
146
+ const outRel = rel.replace(/\.liquid(\.html)?$/i, ".html").replace(ext, ".html");
147
+ const outPath = join(outDir, outRel);
148
+ await ensureDir(dirname(outPath));
149
+
150
+ if (ext === ".md") {
151
+ const raw = await readFile(filePath, "utf8");
152
+ const parsed = matter(raw);
153
+ const html = md.render(parsed.content);
154
+ const ctx = pageContext(parsed.data, html, config, rel, data);
155
+ const rendered = await renderWithLayout(parsed.data.layout, html, ctx, env, liquidEnv);
156
+ await writeFile(outPath, rendered, "utf8");
157
+ return;
158
+ }
159
+
160
+ if (ext === ".njk") {
161
+ const raw = await readFile(filePath, "utf8");
162
+ const parsed = matter(raw);
163
+ const ctx = pageContext(parsed.data, parsed.content, config, rel, data);
164
+ const templateName = rel.replace(/\\/g, "/");
165
+ let pageHtml = env.renderString(parsed.content, ctx, { path: templateName });
166
+ if (shouldRenderMarkdown(parsed.data, config, false)) {
167
+ pageHtml = md.render(pageHtml);
168
+ }
169
+ const rendered = await renderWithLayout(parsed.data.layout, pageHtml, ctx, env, liquidEnv);
170
+ await writeFile(outPath, rendered, "utf8");
171
+ return;
172
+ }
173
+
174
+ if (ext === ".liquid" || filePath.toLowerCase().endsWith(".liquid.html")) {
175
+ const raw = await readFile(filePath, "utf8");
176
+ const parsed = matter(raw);
177
+ const ctx = pageContext(parsed.data, parsed.content, config, rel, data);
178
+ let pageHtml = await liquidEnv.parseAndRender(parsed.content, ctx);
179
+ if (shouldRenderMarkdown(parsed.data, config, false)) {
180
+ pageHtml = md.render(pageHtml);
181
+ }
182
+ const rendered = await renderWithLayout(parsed.data.layout, pageHtml, ctx, env, liquidEnv);
183
+ await writeFile(outPath, rendered, "utf8");
184
+ return;
185
+ }
186
+
187
+ if (ext === ".html") {
188
+ const raw = await readFile(filePath, "utf8");
189
+ const parsed = matter(raw);
190
+ const ctx = pageContext(parsed.data, parsed.content, config, rel, data);
191
+ let pageHtml = parsed.content;
192
+ if (shouldRenderMarkdown(parsed.data, config, false)) {
193
+ pageHtml = md.render(pageHtml);
194
+ }
195
+ const rendered = await renderWithLayout(parsed.data.layout, pageHtml, ctx, env, liquidEnv);
196
+ await writeFile(outPath, rendered, "utf8");
197
+ return;
198
+ }
199
+
200
+ await cp(filePath, outPath);
201
+ }
202
+
203
+ async function build(cwdArg = cwd) {
204
+ const config = await loadConfig(cwdArg);
205
+ const srcDir = resolve(cwdArg, config.srcDir || "src");
206
+ const pagesDir = join(srcDir, "pages");
207
+ const layoutsDir = join(srcDir, "layouts");
208
+ const dataDir = join(srcDir, "data");
209
+ const publicDir = resolve(cwdArg, "public");
210
+ const outDir = resolve(cwdArg, config.outDir || "dist");
211
+ const env = createNunjucksEnv(layoutsDir, pagesDir, srcDir);
212
+ const liquidEnv = createLiquidEnv(layoutsDir, pagesDir, srcDir);
213
+ const data = await loadData(dataDir);
214
+
215
+ await cleanDir(outDir);
216
+ await copyPublic(publicDir, outDir);
217
+
218
+ const files = await walkFiles(pagesDir);
219
+ if (files.length === 0) {
220
+ console.log(kolor.yellow("No pages found in src/pages."));
221
+ return;
222
+ }
223
+
224
+ await Promise.all(files.map((file) => renderPage(file, { pagesDir, layoutsDir, outDir, env, liquidEnv, config, data })));
225
+
226
+ console.log(kolor.green(`Built ${files.length} page(s) → ${relative(cwdArg, outDir)}`));
227
+ }
228
+
229
+ function serve(outDir, port = 4173) {
230
+ const mime = {
231
+ ".html": "text/html",
232
+ ".css": "text/css",
233
+ ".js": "application/javascript",
234
+ ".json": "application/json",
235
+ ".svg": "image/svg+xml",
236
+ ".png": "image/png",
237
+ ".jpg": "image/jpeg",
238
+ ".jpeg": "image/jpeg",
239
+ ".gif": "image/gif",
240
+ ".webp": "image/webp",
241
+ ".ico": "image/x-icon"
242
+ };
243
+
244
+ const server = createServer(async (req, res) => {
245
+ const urlPath = decodeURI((req.url || "/").split("?")[0]);
246
+ const safePath = urlPath.replace(/\.\.+/g, "");
247
+ let filePath = join(outDir, safePath);
248
+ let stats;
249
+
250
+ try {
251
+ stats = await stat(filePath);
252
+ if (stats.isDirectory()) {
253
+ filePath = join(filePath, "index.html");
254
+ stats = await stat(filePath);
255
+ }
256
+ } catch {
257
+ filePath = join(outDir, "index.html");
258
+ }
259
+
260
+ try {
261
+ const data = await readFile(filePath);
262
+ const type = mime[extname(filePath).toLowerCase()] || "text/plain";
263
+ res.writeHead(200, { "Content-Type": type });
264
+ res.end(data);
265
+ } catch {
266
+ res.writeHead(404, { "Content-Type": "text/plain" });
267
+ res.end("Not found");
268
+ }
269
+ });
270
+
271
+ server.listen(port, () => {
272
+ console.log(kolor.green(`Serving dist at http://localhost:${port}`));
273
+ });
274
+
275
+ return server;
276
+ }
277
+
278
+ async function dev(cwdArg = cwd) {
279
+ let building = false;
280
+ let pending = false;
281
+
282
+ const runBuild = async () => {
283
+ if (building) {
284
+ pending = true;
285
+ return;
286
+ }
287
+ building = true;
288
+ try {
289
+ await build(cwdArg);
290
+ } catch (err) {
291
+ console.error(kolor.red(`Build failed: ${err.message}`));
292
+ } finally {
293
+ building = false;
294
+ if (pending) {
295
+ pending = false;
296
+ runBuild();
297
+ }
298
+ }
299
+ };
300
+
301
+ await runBuild();
302
+
303
+ const config = await loadConfig(cwdArg);
304
+ const srcDir = resolve(cwdArg, config.srcDir || "src");
305
+ const dataDir = join(srcDir, "data");
306
+ const publicDir = resolve(cwdArg, "public");
307
+ const outDir = resolve(cwdArg, config.outDir || "dist");
308
+ const watcher = chokidar.watch([srcDir, publicDir, dataDir], { ignoreInitial: true });
309
+
310
+ watcher.on("all", (event, path) => {
311
+ console.log(kolor.cyan(`↻ ${event}: ${relative(cwdArg, path)}`));
312
+ runBuild();
313
+ });
314
+
315
+ serve(outDir);
316
+ }
317
+
318
+ async function main() {
319
+ const command = argv[2] || "help";
320
+
321
+ switch (command) {
322
+ case "dev":
323
+ await dev();
324
+ break;
325
+ case "build":
326
+ await build();
327
+ break;
328
+ case "serve": {
329
+ const config = await loadConfig(cwd);
330
+ const outDir = resolve(cwd, config.outDir || "dist");
331
+ if (!existsSync(outDir)) {
332
+ await build();
333
+ }
334
+ serve(outDir);
335
+ break;
336
+ }
337
+ default:
338
+ console.log("campsite commands: dev | build | serve");
339
+ exit(0);
340
+ }
341
+ }
342
+
343
+ main().catch((err) => {
344
+ console.error(err);
345
+ exit(1);
346
+ });
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "basecampjs",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "description": "BasecampJS engine for Campsite static site generator.",
6
+ "bin": {
7
+ "campsite": "./index.js"
8
+ },
9
+ "license": "MIT",
10
+ "exports": {
11
+ ".": "./index.js"
12
+ },
13
+ "dependencies": {
14
+ "chokidar": "^3.6.0",
15
+ "gray-matter": "^4.0.3",
16
+ "kolorist": "^1.8.0",
17
+ "liquidjs": "^10.12.0",
18
+ "markdown-it": "^14.1.0",
19
+ "nunjucks": "^3.2.4"
20
+ },
21
+ "engines": {
22
+ "node": ">=18"
23
+ }
24
+ }