basecampjs 0.0.6 → 0.0.7

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 +140 -7
  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) {
@@ -175,7 +178,7 @@ function shouldRenderMarkdown(frontmatter, config, defaultValue) {
175
178
  return defaultValue;
176
179
  }
177
180
 
178
- async function renderWithLayout(layoutName, html, ctx, env, liquidEnv) {
181
+ async function renderWithLayout(layoutName, html, ctx, env, liquidEnv, layoutsDir) {
179
182
  if (!layoutName) return html;
180
183
  const ext = extname(layoutName).toLowerCase();
181
184
  const layoutCtx = {
@@ -193,6 +196,14 @@ async function renderWithLayout(layoutName, html, ctx, env, liquidEnv) {
193
196
  return liquidEnv.renderFile(layoutName, layoutCtx);
194
197
  }
195
198
 
199
+ if (ext === ".mustache") {
200
+ const layoutPath = join(layoutsDir, layoutName);
201
+ if (existsSync(layoutPath)) {
202
+ const layoutTemplate = await readFile(layoutPath, "utf8");
203
+ return Mustache.render(layoutTemplate, layoutCtx);
204
+ }
205
+ }
206
+
196
207
  // Unknown layout type, return unwrapped content
197
208
  return html;
198
209
  }
@@ -210,7 +221,7 @@ async function renderPage(filePath, { pagesDir, layoutsDir, outDir, env, liquidE
210
221
  const parsed = matter(raw);
211
222
  const html = md.render(parsed.content);
212
223
  const ctx = pageContext(parsed.data, html, config, rel, data, path);
213
- const rendered = await renderWithLayout(parsed.data.layout, html, ctx, env, liquidEnv);
224
+ const rendered = await renderWithLayout(parsed.data.layout, html, ctx, env, liquidEnv, layoutsDir);
214
225
  await writeFile(outPath, rendered, "utf8");
215
226
  return;
216
227
  }
@@ -224,7 +235,7 @@ async function renderPage(filePath, { pagesDir, layoutsDir, outDir, env, liquidE
224
235
  if (shouldRenderMarkdown(parsed.data, config, false)) {
225
236
  pageHtml = md.render(pageHtml);
226
237
  }
227
- const rendered = await renderWithLayout(parsed.data.layout, pageHtml, ctx, env, liquidEnv);
238
+ const rendered = await renderWithLayout(parsed.data.layout, pageHtml, ctx, env, liquidEnv, layoutsDir);
228
239
  await writeFile(outPath, rendered, "utf8");
229
240
  return;
230
241
  }
@@ -237,7 +248,20 @@ async function renderPage(filePath, { pagesDir, layoutsDir, outDir, env, liquidE
237
248
  if (shouldRenderMarkdown(parsed.data, config, false)) {
238
249
  pageHtml = md.render(pageHtml);
239
250
  }
240
- const rendered = await renderWithLayout(parsed.data.layout, pageHtml, ctx, env, liquidEnv);
251
+ const rendered = await renderWithLayout(parsed.data.layout, pageHtml, ctx, env, liquidEnv, layoutsDir);
252
+ await writeFile(outPath, rendered, "utf8");
253
+ return;
254
+ }
255
+
256
+ if (ext === ".mustache") {
257
+ const raw = await readFile(filePath, "utf8");
258
+ const parsed = matter(raw);
259
+ const ctx = pageContext(parsed.data, parsed.content, config, rel, data, path);
260
+ let pageHtml = Mustache.render(parsed.content, ctx);
261
+ if (shouldRenderMarkdown(parsed.data, config, false)) {
262
+ pageHtml = md.render(pageHtml);
263
+ }
264
+ const rendered = await renderWithLayout(parsed.data.layout, pageHtml, ctx, env, liquidEnv, layoutsDir);
241
265
  await writeFile(outPath, rendered, "utf8");
242
266
  return;
243
267
  }
@@ -250,7 +274,7 @@ async function renderPage(filePath, { pagesDir, layoutsDir, outDir, env, liquidE
250
274
  if (shouldRenderMarkdown(parsed.data, config, false)) {
251
275
  pageHtml = md.render(pageHtml);
252
276
  }
253
- const rendered = await renderWithLayout(parsed.data.layout, pageHtml, ctx, env, liquidEnv);
277
+ const rendered = await renderWithLayout(parsed.data.layout, pageHtml, ctx, env, liquidEnv, layoutsDir);
254
278
  await writeFile(outPath, rendered, "utf8");
255
279
  return;
256
280
  }
@@ -258,6 +282,88 @@ async function renderPage(filePath, { pagesDir, layoutsDir, outDir, env, liquidE
258
282
  await cp(filePath, outPath);
259
283
  }
260
284
 
285
+ async function cacheBustAssets(outDir) {
286
+ const assetMap = {}; // original path -> hashed path
287
+ const files = await walkFiles(outDir);
288
+ const assetFiles = files.filter((file) => {
289
+ const ext = extname(file).toLowerCase();
290
+ return ext === ".css" || ext === ".js";
291
+ });
292
+
293
+ // Hash and rename each asset
294
+ for (const file of assetFiles) {
295
+ try {
296
+ const content = await readFile(file);
297
+ const hash = createHash("sha256").update(content).digest("hex").slice(0, 10);
298
+ const ext = extname(file);
299
+ const base = basename(file, ext);
300
+ const dir = dirname(file);
301
+ const hashedName = `${base}-${hash}${ext}`;
302
+ const hashedPath = join(dir, hashedName);
303
+
304
+ await rename(file, hashedPath);
305
+
306
+ // Store mapping of original relative path to hashed relative path
307
+ const originalRel = relative(outDir, file).replace(/\\/g, "/");
308
+ const hashedRel = relative(outDir, hashedPath).replace(/\\/g, "/");
309
+ assetMap[originalRel] = hashedRel;
310
+
311
+ // Log the cache-busted file
312
+ console.log(kolor.dim(` ${originalRel}`) + kolor.cyan(` → `) + kolor.green(hashedRel));
313
+ } catch (err) {
314
+ console.error(kolor.red(`Failed to cache-bust ${relative(outDir, file)}: ${err.message}`));
315
+ }
316
+ }
317
+
318
+ // Update HTML files to reference hashed assets
319
+ const htmlFiles = files.filter((file) => extname(file).toLowerCase() === ".html");
320
+
321
+ for (const htmlFile of htmlFiles) {
322
+ try {
323
+ let html = await readFile(htmlFile, "utf8");
324
+ let updated = false;
325
+
326
+ // Update <link> tags (CSS)
327
+ for (const [original, hashed] of Object.entries(assetMap)) {
328
+ if (original.endsWith(".css")) {
329
+ // Match href="/path" or href="path" but ensure we stay within the tag
330
+ const escaped = original.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
331
+ const pattern = new RegExp(`(<link[^>]*?\\shref=["'])/?${escaped}(["'])`, "gi");
332
+
333
+ const newHtml = html.replace(pattern, `$1/${hashed}$2`);
334
+ if (newHtml !== html) {
335
+ html = newHtml;
336
+ updated = true;
337
+ }
338
+ }
339
+ }
340
+
341
+ // Update <script> tags (JS)
342
+ for (const [original, hashed] of Object.entries(assetMap)) {
343
+ if (original.endsWith(".js")) {
344
+ // Match src="/path" or src="path" but ensure we stay within the tag
345
+ const escaped = original.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
346
+ const pattern = new RegExp(`(<script[^>]*?\\ssrc=["'])/?${escaped}(["'])`, "gi");
347
+
348
+ const newHtml = html.replace(pattern, `$1/${hashed}$2`);
349
+ if (newHtml !== html) {
350
+ html = newHtml;
351
+ updated = true;
352
+ }
353
+ }
354
+ }
355
+
356
+ if (updated) {
357
+ await writeFile(htmlFile, html, "utf8");
358
+ }
359
+ } catch (err) {
360
+ console.error(kolor.red(`Failed to update asset references in ${relative(outDir, htmlFile)}: ${err.message}`));
361
+ }
362
+ }
363
+
364
+ return assetMap;
365
+ }
366
+
261
367
  async function build(cwdArg = cwd) {
262
368
  const config = await loadConfig(cwdArg);
263
369
  const srcDir = resolve(cwdArg, config.srcDir || "src");
@@ -268,7 +374,23 @@ async function build(cwdArg = cwd) {
268
374
  const publicDir = resolve(cwdArg, "public");
269
375
  const outDir = resolve(cwdArg, config.outDir || "dist");
270
376
  const env = createNunjucksEnv(layoutsDir, pagesDir, srcDir, partialsDir);
377
+ // Allow user config to extend the Nunjucks environment (e.g., custom filters)
378
+ if (config?.hooks?.nunjucksEnv && typeof config.hooks.nunjucksEnv === "function") {
379
+ try {
380
+ config.hooks.nunjucksEnv(env);
381
+ } catch (err) {
382
+ console.error(kolor.red(`Failed to apply nunjucksEnv hook: ${err.message}`));
383
+ }
384
+ }
271
385
  const liquidEnv = createLiquidEnv(layoutsDir, pagesDir, srcDir, partialsDir);
386
+ // Allow user config to extend the Liquid environment (e.g., custom filters)
387
+ if (config?.hooks?.liquidEnv && typeof config.hooks.liquidEnv === "function") {
388
+ try {
389
+ config.hooks.liquidEnv(liquidEnv);
390
+ } catch (err) {
391
+ console.error(kolor.red(`Failed to apply liquidEnv hook: ${err.message}`));
392
+ }
393
+ }
272
394
  const data = await loadData(dataDir);
273
395
 
274
396
  await cleanDir(outDir);
@@ -292,6 +414,17 @@ async function build(cwdArg = cwd) {
292
414
  console.log(kolor.green("HTML minified"));
293
415
  }
294
416
 
417
+ if (config.cacheBustAssets) {
418
+ console.log(kolor.cyan("Cache-busting assets..."));
419
+ const assetMap = await cacheBustAssets(outDir);
420
+ const assetCount = Object.keys(assetMap).length;
421
+ if (assetCount > 0) {
422
+ console.log(kolor.green(`✓ Cache-busted ${assetCount} asset(s)`));
423
+ } else {
424
+ console.log(kolor.yellow("No assets found to cache-bust"));
425
+ }
426
+ }
427
+
295
428
  console.log(kolor.green(`Built ${files.length} page(s) → ${relative(cwdArg, outDir)}`));
296
429
  }
297
430
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "basecampjs",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
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": {