basecampjs 0.0.6 → 0.0.8

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 +160 -20
  2. package/package.json +2 -1
package/index.js CHANGED
@@ -2,13 +2,15 @@
2
2
  import { argv, exit } from "process";
3
3
  import { createServer } from "http";
4
4
  import { existsSync } from "fs";
5
- import { cp, mkdir, readFile, readdir, rm, stat, writeFile } from "fs/promises";
5
+ import { cp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "fs/promises";
6
6
  import { basename, dirname, extname, join, relative, resolve } from "path";
7
7
  import { pathToFileURL } from "url";
8
+ import { createHash } from "crypto";
8
9
  import * as kolor from "kolorist";
9
10
  import chokidar from "chokidar";
10
11
  import matter from "gray-matter";
11
12
  import MarkdownIt from "markdown-it";
13
+ import Mustache from "mustache";
12
14
  import nunjucks from "nunjucks";
13
15
  import { Liquid } from "liquidjs";
14
16
  import { minify as minifyCss } from "csso";
@@ -25,7 +27,8 @@ const defaultConfig = {
25
27
  markdown: true,
26
28
  minifyCSS: false,
27
29
  minifyHTML: false,
28
- integrations: { nunjucks: true, liquid: false, vue: false, alpine: false }
30
+ cacheBustAssets: false,
31
+ integrations: { nunjucks: true, liquid: false, mustache: false, vue: false, alpine: false }
29
32
  };
30
33
 
31
34
  async function loadConfig(root) {
@@ -45,18 +48,23 @@ async function ensureDir(dir) {
45
48
  await mkdir(dir, { recursive: true });
46
49
  }
47
50
 
48
- async function loadData(dataDir) {
51
+ async function loadData(dataDirs) {
49
52
  const collections = {};
50
- if (!existsSync(dataDir)) return collections;
51
- const files = await walkFiles(dataDir);
52
- for (const file of files) {
53
- if (extname(file).toLowerCase() !== ".json") continue;
54
- const name = basename(file, ".json");
55
- try {
56
- const raw = await readFile(file, "utf8");
57
- collections[name] = JSON.parse(raw);
58
- } catch (err) {
59
- console.error(kolor.red(`Failed to load data ${relative(dataDir, file)}: ${err.message}`));
53
+ // Support both string and array input
54
+ const dirs = Array.isArray(dataDirs) ? dataDirs : [dataDirs];
55
+
56
+ for (const dataDir of dirs) {
57
+ if (!existsSync(dataDir)) continue;
58
+ const files = await walkFiles(dataDir);
59
+ for (const file of files) {
60
+ if (extname(file).toLowerCase() !== ".json") continue;
61
+ const name = basename(file, ".json");
62
+ try {
63
+ const raw = await readFile(file, "utf8");
64
+ collections[name] = JSON.parse(raw);
65
+ } catch (err) {
66
+ console.error(kolor.red(`Failed to load data ${relative(dataDir, file)}: ${err.message}`));
67
+ }
60
68
  }
61
69
  }
62
70
  return collections;
@@ -175,7 +183,7 @@ function shouldRenderMarkdown(frontmatter, config, defaultValue) {
175
183
  return defaultValue;
176
184
  }
177
185
 
178
- async function renderWithLayout(layoutName, html, ctx, env, liquidEnv) {
186
+ async function renderWithLayout(layoutName, html, ctx, env, liquidEnv, layoutsDir) {
179
187
  if (!layoutName) return html;
180
188
  const ext = extname(layoutName).toLowerCase();
181
189
  const layoutCtx = {
@@ -193,6 +201,14 @@ async function renderWithLayout(layoutName, html, ctx, env, liquidEnv) {
193
201
  return liquidEnv.renderFile(layoutName, layoutCtx);
194
202
  }
195
203
 
204
+ if (ext === ".mustache") {
205
+ const layoutPath = join(layoutsDir, layoutName);
206
+ if (existsSync(layoutPath)) {
207
+ const layoutTemplate = await readFile(layoutPath, "utf8");
208
+ return Mustache.render(layoutTemplate, layoutCtx);
209
+ }
210
+ }
211
+
196
212
  // Unknown layout type, return unwrapped content
197
213
  return html;
198
214
  }
@@ -210,7 +226,7 @@ async function renderPage(filePath, { pagesDir, layoutsDir, outDir, env, liquidE
210
226
  const parsed = matter(raw);
211
227
  const html = md.render(parsed.content);
212
228
  const ctx = pageContext(parsed.data, html, config, rel, data, path);
213
- const rendered = await renderWithLayout(parsed.data.layout, html, ctx, env, liquidEnv);
229
+ const rendered = await renderWithLayout(parsed.data.layout, html, ctx, env, liquidEnv, layoutsDir);
214
230
  await writeFile(outPath, rendered, "utf8");
215
231
  return;
216
232
  }
@@ -224,7 +240,7 @@ async function renderPage(filePath, { pagesDir, layoutsDir, outDir, env, liquidE
224
240
  if (shouldRenderMarkdown(parsed.data, config, false)) {
225
241
  pageHtml = md.render(pageHtml);
226
242
  }
227
- const rendered = await renderWithLayout(parsed.data.layout, pageHtml, ctx, env, liquidEnv);
243
+ const rendered = await renderWithLayout(parsed.data.layout, pageHtml, ctx, env, liquidEnv, layoutsDir);
228
244
  await writeFile(outPath, rendered, "utf8");
229
245
  return;
230
246
  }
@@ -237,7 +253,20 @@ async function renderPage(filePath, { pagesDir, layoutsDir, outDir, env, liquidE
237
253
  if (shouldRenderMarkdown(parsed.data, config, false)) {
238
254
  pageHtml = md.render(pageHtml);
239
255
  }
240
- const rendered = await renderWithLayout(parsed.data.layout, pageHtml, ctx, env, liquidEnv);
256
+ const rendered = await renderWithLayout(parsed.data.layout, pageHtml, ctx, env, liquidEnv, layoutsDir);
257
+ await writeFile(outPath, rendered, "utf8");
258
+ return;
259
+ }
260
+
261
+ if (ext === ".mustache") {
262
+ const raw = await readFile(filePath, "utf8");
263
+ const parsed = matter(raw);
264
+ const ctx = pageContext(parsed.data, parsed.content, config, rel, data, path);
265
+ let pageHtml = Mustache.render(parsed.content, ctx);
266
+ if (shouldRenderMarkdown(parsed.data, config, false)) {
267
+ pageHtml = md.render(pageHtml);
268
+ }
269
+ const rendered = await renderWithLayout(parsed.data.layout, pageHtml, ctx, env, liquidEnv, layoutsDir);
241
270
  await writeFile(outPath, rendered, "utf8");
242
271
  return;
243
272
  }
@@ -250,7 +279,7 @@ async function renderPage(filePath, { pagesDir, layoutsDir, outDir, env, liquidE
250
279
  if (shouldRenderMarkdown(parsed.data, config, false)) {
251
280
  pageHtml = md.render(pageHtml);
252
281
  }
253
- const rendered = await renderWithLayout(parsed.data.layout, pageHtml, ctx, env, liquidEnv);
282
+ const rendered = await renderWithLayout(parsed.data.layout, pageHtml, ctx, env, liquidEnv, layoutsDir);
254
283
  await writeFile(outPath, rendered, "utf8");
255
284
  return;
256
285
  }
@@ -258,6 +287,88 @@ async function renderPage(filePath, { pagesDir, layoutsDir, outDir, env, liquidE
258
287
  await cp(filePath, outPath);
259
288
  }
260
289
 
290
+ async function cacheBustAssets(outDir) {
291
+ const assetMap = {}; // original path -> hashed path
292
+ const files = await walkFiles(outDir);
293
+ const assetFiles = files.filter((file) => {
294
+ const ext = extname(file).toLowerCase();
295
+ return ext === ".css" || ext === ".js";
296
+ });
297
+
298
+ // Hash and rename each asset
299
+ for (const file of assetFiles) {
300
+ try {
301
+ const content = await readFile(file);
302
+ const hash = createHash("sha256").update(content).digest("hex").slice(0, 10);
303
+ const ext = extname(file);
304
+ const base = basename(file, ext);
305
+ const dir = dirname(file);
306
+ const hashedName = `${base}-${hash}${ext}`;
307
+ const hashedPath = join(dir, hashedName);
308
+
309
+ await rename(file, hashedPath);
310
+
311
+ // Store mapping of original relative path to hashed relative path
312
+ const originalRel = relative(outDir, file).replace(/\\/g, "/");
313
+ const hashedRel = relative(outDir, hashedPath).replace(/\\/g, "/");
314
+ assetMap[originalRel] = hashedRel;
315
+
316
+ // Log the cache-busted file
317
+ console.log(kolor.dim(` ${originalRel}`) + kolor.cyan(` → `) + kolor.green(hashedRel));
318
+ } catch (err) {
319
+ console.error(kolor.red(`Failed to cache-bust ${relative(outDir, file)}: ${err.message}`));
320
+ }
321
+ }
322
+
323
+ // Update HTML files to reference hashed assets
324
+ const htmlFiles = files.filter((file) => extname(file).toLowerCase() === ".html");
325
+
326
+ for (const htmlFile of htmlFiles) {
327
+ try {
328
+ let html = await readFile(htmlFile, "utf8");
329
+ let updated = false;
330
+
331
+ // Update <link> tags (CSS)
332
+ for (const [original, hashed] of Object.entries(assetMap)) {
333
+ if (original.endsWith(".css")) {
334
+ // Match href="/path" or href="path" but ensure we stay within the tag
335
+ const escaped = original.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
336
+ const pattern = new RegExp(`(<link[^>]*?\\shref=["'])/?${escaped}(["'])`, "gi");
337
+
338
+ const newHtml = html.replace(pattern, `$1/${hashed}$2`);
339
+ if (newHtml !== html) {
340
+ html = newHtml;
341
+ updated = true;
342
+ }
343
+ }
344
+ }
345
+
346
+ // Update <script> tags (JS)
347
+ for (const [original, hashed] of Object.entries(assetMap)) {
348
+ if (original.endsWith(".js")) {
349
+ // Match src="/path" or src="path" but ensure we stay within the tag
350
+ const escaped = original.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
351
+ const pattern = new RegExp(`(<script[^>]*?\\ssrc=["'])/?${escaped}(["'])`, "gi");
352
+
353
+ const newHtml = html.replace(pattern, `$1/${hashed}$2`);
354
+ if (newHtml !== html) {
355
+ html = newHtml;
356
+ updated = true;
357
+ }
358
+ }
359
+ }
360
+
361
+ if (updated) {
362
+ await writeFile(htmlFile, html, "utf8");
363
+ }
364
+ } catch (err) {
365
+ console.error(kolor.red(`Failed to update asset references in ${relative(outDir, htmlFile)}: ${err.message}`));
366
+ }
367
+ }
368
+
369
+ return assetMap;
370
+ }
371
+
261
372
  async function build(cwdArg = cwd) {
262
373
  const config = await loadConfig(cwdArg);
263
374
  const srcDir = resolve(cwdArg, config.srcDir || "src");
@@ -265,11 +376,28 @@ async function build(cwdArg = cwd) {
265
376
  const layoutsDir = join(srcDir, "layouts");
266
377
  const partialsDir = join(srcDir, "partials");
267
378
  const dataDir = join(srcDir, "data");
379
+ const collectionsDir = join(srcDir, "collections");
268
380
  const publicDir = resolve(cwdArg, "public");
269
381
  const outDir = resolve(cwdArg, config.outDir || "dist");
270
382
  const env = createNunjucksEnv(layoutsDir, pagesDir, srcDir, partialsDir);
383
+ // Allow user config to extend the Nunjucks environment (e.g., custom filters)
384
+ if (config?.hooks?.nunjucksEnv && typeof config.hooks.nunjucksEnv === "function") {
385
+ try {
386
+ config.hooks.nunjucksEnv(env);
387
+ } catch (err) {
388
+ console.error(kolor.red(`Failed to apply nunjucksEnv hook: ${err.message}`));
389
+ }
390
+ }
271
391
  const liquidEnv = createLiquidEnv(layoutsDir, pagesDir, srcDir, partialsDir);
272
- const data = await loadData(dataDir);
392
+ // Allow user config to extend the Liquid environment (e.g., custom filters)
393
+ if (config?.hooks?.liquidEnv && typeof config.hooks.liquidEnv === "function") {
394
+ try {
395
+ config.hooks.liquidEnv(liquidEnv);
396
+ } catch (err) {
397
+ console.error(kolor.red(`Failed to apply liquidEnv hook: ${err.message}`));
398
+ }
399
+ }
400
+ const data = await loadData([dataDir, collectionsDir]);
273
401
 
274
402
  await cleanDir(outDir);
275
403
  await copyPublic(publicDir, outDir);
@@ -292,6 +420,17 @@ async function build(cwdArg = cwd) {
292
420
  console.log(kolor.green("HTML minified"));
293
421
  }
294
422
 
423
+ if (config.cacheBustAssets) {
424
+ console.log(kolor.cyan("Cache-busting assets..."));
425
+ const assetMap = await cacheBustAssets(outDir);
426
+ const assetCount = Object.keys(assetMap).length;
427
+ if (assetCount > 0) {
428
+ console.log(kolor.green(`✓ Cache-busted ${assetCount} asset(s)`));
429
+ } else {
430
+ console.log(kolor.yellow("No assets found to cache-bust"));
431
+ }
432
+ }
433
+
295
434
  console.log(kolor.green(`Built ${files.length} page(s) → ${relative(cwdArg, outDir)}`));
296
435
  }
297
436
 
@@ -381,9 +520,10 @@ async function dev(cwdArg = cwd) {
381
520
  const config = await loadConfig(cwdArg);
382
521
  const srcDir = resolve(cwdArg, config.srcDir || "src");
383
522
  const dataDir = join(srcDir, "data");
523
+ const collectionsDir = join(srcDir, "collections");
384
524
  const publicDir = resolve(cwdArg, "public");
385
525
  const outDir = resolve(cwdArg, config.outDir || "dist");
386
- const watcher = chokidar.watch([srcDir, publicDir, dataDir], { ignoreInitial: true });
526
+ const watcher = chokidar.watch([srcDir, publicDir, dataDir, collectionsDir], { ignoreInitial: true });
387
527
 
388
528
  watcher.on("all", (event, path) => {
389
529
  console.log(kolor.cyan(`↻ ${event}: ${relative(cwdArg, path)}`));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "basecampjs",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
4
4
  "type": "module",
5
5
  "description": "BasecampJS engine for CampsiteJS static site generator.",
6
6
  "bin": {
@@ -18,6 +18,7 @@
18
18
  "kolorist": "^1.8.0",
19
19
  "liquidjs": "^10.12.0",
20
20
  "markdown-it": "^14.1.0",
21
+ "mustache": "^4.2.0",
21
22
  "nunjucks": "^3.2.4"
22
23
  },
23
24
  "engines": {