bunki 0.17.0 → 0.17.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.
@@ -12,6 +12,7 @@ export declare function handleGenerateCommand(options: {
12
12
  content: string;
13
13
  output: string;
14
14
  templates: string;
15
+ incremental?: boolean;
15
16
  }, deps?: GenerateDeps): Promise<void>;
16
17
  export declare function registerGenerateCommand(program: Command): Command;
17
18
  export {};
package/dist/cli.js CHANGED
@@ -28478,12 +28478,12 @@ function registerCssCommand(program2) {
28478
28478
  }
28479
28479
 
28480
28480
  // src/cli/commands/generate.ts
28481
- import path9 from "path";
28481
+ import path10 from "path";
28482
28482
 
28483
28483
  // src/site-generator.ts
28484
28484
  var import_nunjucks2 = __toESM(require_nunjucks(), 1);
28485
28485
  var import_slugify = __toESM(require_slugify(), 1);
28486
- import path8 from "path";
28486
+ import path9 from "path";
28487
28487
 
28488
28488
  // src/parser.ts
28489
28489
  import path5 from "path";
@@ -33368,6 +33368,20 @@ function detectFileConflicts(files) {
33368
33368
  }
33369
33369
  return errors;
33370
33370
  }
33371
+ async function parseMarkdownFiles(filePaths, cdnConfig) {
33372
+ const resultsPromises = filePaths.map((filePath) => parseMarkdownFile(filePath, cdnConfig).then((result) => ({
33373
+ result,
33374
+ filePath
33375
+ })));
33376
+ const results = await Promise.all(resultsPromises);
33377
+ const postsWithPaths = [];
33378
+ for (const { result, filePath } of results) {
33379
+ if (result.post) {
33380
+ postsWithPaths.push({ post: result.post, filePath });
33381
+ }
33382
+ }
33383
+ return postsWithPaths;
33384
+ }
33371
33385
  async function parseMarkdownDirectory(contentDir, strictMode = false, cdnConfig) {
33372
33386
  try {
33373
33387
  const markdownFiles = await findFilesByPattern("**/*.md", contentDir, true);
@@ -33638,6 +33652,165 @@ function generateHomePageSchemas(options2) {
33638
33652
  return schemas;
33639
33653
  }
33640
33654
 
33655
+ // src/utils/build-cache.ts
33656
+ var {hash: hash2 } = globalThis.Bun;
33657
+ import path6 from "path";
33658
+ var CACHE_VERSION = "2.0.0";
33659
+ var CACHE_FILENAME = ".bunki-cache.json";
33660
+ async function hashFile(filePath) {
33661
+ try {
33662
+ const file = Bun.file(filePath);
33663
+ const content = await file.arrayBuffer();
33664
+ return hash2(content).toString(36);
33665
+ } catch (error) {
33666
+ return "";
33667
+ }
33668
+ }
33669
+ async function getFileMtime(filePath) {
33670
+ try {
33671
+ const stat = await Bun.file(filePath).stat();
33672
+ return stat?.mtime?.getTime() || 0;
33673
+ } catch (error) {
33674
+ return 0;
33675
+ }
33676
+ }
33677
+ async function loadCache(cwd) {
33678
+ const cachePath = path6.join(cwd, CACHE_FILENAME);
33679
+ const cacheFile = Bun.file(cachePath);
33680
+ try {
33681
+ if (await cacheFile.exists()) {
33682
+ const content = await cacheFile.text();
33683
+ const cache = JSON.parse(content);
33684
+ if (cache.version !== CACHE_VERSION) {
33685
+ console.log(`Cache version mismatch (${cache.version} vs ${CACHE_VERSION}), rebuilding...`);
33686
+ return createEmptyCache();
33687
+ }
33688
+ return cache;
33689
+ }
33690
+ } catch (error) {
33691
+ console.warn("Error loading cache, rebuilding:", error);
33692
+ }
33693
+ return createEmptyCache();
33694
+ }
33695
+ async function saveCache(cwd, cache) {
33696
+ const cachePath = path6.join(cwd, CACHE_FILENAME);
33697
+ try {
33698
+ await Bun.write(cachePath, JSON.stringify(cache, null, 2));
33699
+ } catch (error) {
33700
+ console.warn("Error saving cache:", error);
33701
+ }
33702
+ }
33703
+ function createEmptyCache() {
33704
+ return {
33705
+ version: CACHE_VERSION,
33706
+ files: {}
33707
+ };
33708
+ }
33709
+ async function hasFileChanged(filePath, cache) {
33710
+ const cached = cache.files[filePath];
33711
+ if (!cached) {
33712
+ return true;
33713
+ }
33714
+ const currentMtime = await getFileMtime(filePath);
33715
+ if (currentMtime !== cached.mtime) {
33716
+ const currentHash = await hashFile(filePath);
33717
+ return currentHash !== cached.hash;
33718
+ }
33719
+ return false;
33720
+ }
33721
+ async function updateCacheEntry(filePath, cache, options2) {
33722
+ const currentHash = await hashFile(filePath);
33723
+ const currentMtime = await getFileMtime(filePath);
33724
+ cache.files[filePath] = {
33725
+ hash: currentHash,
33726
+ mtime: currentMtime,
33727
+ post: options2?.post,
33728
+ outputs: options2?.outputs
33729
+ };
33730
+ }
33731
+ async function hasConfigChanged(configPath, cache) {
33732
+ const currentHash = await hashFile(configPath);
33733
+ if (!cache.configHash) {
33734
+ cache.configHash = currentHash;
33735
+ return true;
33736
+ }
33737
+ if (currentHash !== cache.configHash) {
33738
+ cache.configHash = currentHash;
33739
+ return true;
33740
+ }
33741
+ return false;
33742
+ }
33743
+ function loadCachedPosts(cache, filePaths) {
33744
+ const posts = [];
33745
+ for (const filePath of filePaths) {
33746
+ const entry = cache.files[filePath];
33747
+ if (entry?.post) {
33748
+ posts.push(entry.post);
33749
+ }
33750
+ }
33751
+ return posts;
33752
+ }
33753
+
33754
+ // src/utils/change-detector.ts
33755
+ async function detectChanges(currentFiles, cache, options2 = {}) {
33756
+ const changes = {
33757
+ changedPosts: [],
33758
+ deletedPosts: [],
33759
+ stylesChanged: false,
33760
+ configChanged: false,
33761
+ templatesChanged: false,
33762
+ fullRebuild: false
33763
+ };
33764
+ if (options2.configPath) {
33765
+ const configChanged = await hasFileChanged(options2.configPath, cache);
33766
+ if (configChanged) {
33767
+ changes.configChanged = true;
33768
+ changes.fullRebuild = true;
33769
+ return changes;
33770
+ }
33771
+ }
33772
+ if (options2.templatePaths && options2.templatePaths.length > 0) {
33773
+ for (const templatePath of options2.templatePaths) {
33774
+ const changed = await hasFileChanged(templatePath, cache);
33775
+ if (changed) {
33776
+ changes.templatesChanged = true;
33777
+ changes.fullRebuild = true;
33778
+ return changes;
33779
+ }
33780
+ }
33781
+ }
33782
+ if (options2.stylesPaths && options2.stylesPaths.length > 0) {
33783
+ for (const stylePath of options2.stylesPaths) {
33784
+ const changed = await hasFileChanged(stylePath, cache);
33785
+ if (changed) {
33786
+ changes.stylesChanged = true;
33787
+ break;
33788
+ }
33789
+ }
33790
+ }
33791
+ for (const filePath of currentFiles) {
33792
+ const changed = await hasFileChanged(filePath, cache);
33793
+ if (changed) {
33794
+ changes.changedPosts.push(filePath);
33795
+ }
33796
+ }
33797
+ const cachedFiles = Object.keys(cache.files).filter((f) => f.endsWith(".md"));
33798
+ for (const cachedFile of cachedFiles) {
33799
+ if (!currentFiles.includes(cachedFile)) {
33800
+ changes.deletedPosts.push(cachedFile);
33801
+ }
33802
+ }
33803
+ if (changes.deletedPosts.length > 0) {
33804
+ changes.fullRebuild = true;
33805
+ }
33806
+ return changes;
33807
+ }
33808
+ function estimateTimeSaved(totalPosts, changedPosts) {
33809
+ const avgTimePerPost = 6;
33810
+ const skippedPosts = totalPosts - changedPosts;
33811
+ return skippedPosts * avgTimePerPost;
33812
+ }
33813
+
33641
33814
  // src/utils/xml-builder.ts
33642
33815
  function escapeXml(text) {
33643
33816
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
@@ -33870,14 +34043,14 @@ Sitemap: ${config.baseUrl}/sitemap.xml
33870
34043
 
33871
34044
  // src/generators/pages.ts
33872
34045
  var import_nunjucks = __toESM(require_nunjucks(), 1);
33873
- import path6 from "path";
34046
+ import path7 from "path";
33874
34047
  function getSortedTags(tags, limit) {
33875
34048
  const sorted = Object.values(tags).sort((a, b2) => b2.count - a.count);
33876
34049
  return limit ? sorted.slice(0, limit) : sorted;
33877
34050
  }
33878
34051
  async function writeHtmlFile(outputDir, relativePath, content) {
33879
- const fullPath = path6.join(outputDir, relativePath);
33880
- const dir = path6.dirname(fullPath);
34052
+ const fullPath = path7.join(outputDir, relativePath);
34053
+ const dir = path7.dirname(fullPath);
33881
34054
  await ensureDir(dir);
33882
34055
  await Bun.write(fullPath, content);
33883
34056
  }
@@ -34053,7 +34226,7 @@ async function generateMapPage(site, config, outputDir) {
34053
34226
 
34054
34227
  // src/generators/assets.ts
34055
34228
  var {Glob: Glob2 } = globalThis.Bun;
34056
- import path7 from "path";
34229
+ import path8 from "path";
34057
34230
  async function generateStylesheet(config, outputDir) {
34058
34231
  const cssConfig = config.css || getDefaultCSSConfig();
34059
34232
  if (!cssConfig.enabled) {
@@ -34074,15 +34247,15 @@ async function generateStylesheet(config, outputDir) {
34074
34247
  }
34075
34248
  }
34076
34249
  async function fallbackCSSGeneration(cssConfig, outputDir) {
34077
- const cssFilePath = path7.resolve(process.cwd(), cssConfig.input);
34250
+ const cssFilePath = path8.resolve(process.cwd(), cssConfig.input);
34078
34251
  const cssFile = Bun.file(cssFilePath);
34079
34252
  if (!await cssFile.exists()) {
34080
34253
  console.warn(`CSS input file not found: ${cssFilePath}`);
34081
34254
  return;
34082
34255
  }
34083
34256
  try {
34084
- const outputPath = path7.resolve(outputDir, cssConfig.output);
34085
- const outputDirPath = path7.dirname(outputPath);
34257
+ const outputPath = path8.resolve(outputDir, cssConfig.output);
34258
+ const outputDirPath = path8.dirname(outputPath);
34086
34259
  await ensureDir(outputDirPath);
34087
34260
  await Bun.write(outputPath, cssFile);
34088
34261
  console.log("\u2705 CSS file copied successfully (fallback mode)");
@@ -34091,19 +34264,19 @@ async function fallbackCSSGeneration(cssConfig, outputDir) {
34091
34264
  }
34092
34265
  }
34093
34266
  async function copyStaticAssets(templatesDir, outputDir) {
34094
- const assetsDir = path7.join(templatesDir, "assets");
34095
- const publicDir = path7.join(process.cwd(), "public");
34267
+ const assetsDir = path8.join(templatesDir, "assets");
34268
+ const publicDir = path8.join(process.cwd(), "public");
34096
34269
  if (await isDirectory(assetsDir)) {
34097
34270
  const assetGlob = new Glob2("**/*.*");
34098
- const assetsOutputDir = path7.join(outputDir, "assets");
34271
+ const assetsOutputDir = path8.join(outputDir, "assets");
34099
34272
  await ensureDir(assetsOutputDir);
34100
34273
  for await (const file of assetGlob.scan({
34101
34274
  cwd: assetsDir,
34102
34275
  absolute: true
34103
34276
  })) {
34104
- const relativePath = path7.relative(assetsDir, file);
34105
- const targetPath = path7.join(assetsOutputDir, relativePath);
34106
- const targetDir = path7.dirname(targetPath);
34277
+ const relativePath = path8.relative(assetsDir, file);
34278
+ const targetPath = path8.join(assetsOutputDir, relativePath);
34279
+ const targetDir = path8.dirname(targetPath);
34107
34280
  await ensureDir(targetDir);
34108
34281
  await copyFile(file, targetPath);
34109
34282
  }
@@ -34117,9 +34290,9 @@ async function copyStaticAssets(templatesDir, outputDir) {
34117
34290
  })) {
34118
34291
  if (await isDirectory(file))
34119
34292
  continue;
34120
- const relativePath = path7.relative(publicDir, file);
34121
- const destPath = path7.join(outputDir, relativePath);
34122
- const targetDir = path7.dirname(destPath);
34293
+ const relativePath = path8.relative(publicDir, file);
34294
+ const destPath = path8.join(outputDir, relativePath);
34295
+ const targetDir = path8.dirname(destPath);
34123
34296
  await ensureDir(targetDir);
34124
34297
  await copyFile(file, destPath);
34125
34298
  }
@@ -34201,6 +34374,8 @@ class SiteGenerator {
34201
34374
  options;
34202
34375
  site;
34203
34376
  metrics;
34377
+ cache = null;
34378
+ incrementalMode = false;
34204
34379
  constructor(options2) {
34205
34380
  this.options = options2;
34206
34381
  this.site = {
@@ -34246,6 +34421,9 @@ class SiteGenerator {
34246
34421
  }
34247
34422
  });
34248
34423
  }
34424
+ enableIncrementalMode() {
34425
+ this.incrementalMode = true;
34426
+ }
34249
34427
  async initialize() {
34250
34428
  this.metrics.startStage("initialization");
34251
34429
  console.log("Initializing site generator...");
@@ -34254,7 +34432,7 @@ class SiteGenerator {
34254
34432
  setNoFollowExceptions(this.options.config.noFollowExceptions);
34255
34433
  }
34256
34434
  let tagDescriptions = {};
34257
- const tagsTomlPath = path8.join(process.cwd(), "src", "tags.toml");
34435
+ const tagsTomlPath = path9.join(process.cwd(), "src", "tags.toml");
34258
34436
  const tagsTomlFile = Bun.file(tagsTomlPath);
34259
34437
  if (await tagsTomlFile.exists()) {
34260
34438
  try {
@@ -34264,8 +34442,10 @@ class SiteGenerator {
34264
34442
  console.warn("Error loading tag descriptions:", error);
34265
34443
  }
34266
34444
  }
34267
- const strictMode = this.options.config.strictMode ?? false;
34268
- const posts = await parseMarkdownDirectory(this.options.contentDir, strictMode, this.options.config.cdn);
34445
+ if (this.incrementalMode) {
34446
+ this.cache = await loadCache(process.cwd());
34447
+ }
34448
+ const posts = await this.parseContent();
34269
34449
  const tags = {};
34270
34450
  posts.forEach((post) => {
34271
34451
  post.tagSlugs = {};
@@ -34303,7 +34483,21 @@ class SiteGenerator {
34303
34483
  console.log("Generating static site...");
34304
34484
  await ensureDir(this.options.outputDir);
34305
34485
  this.metrics.startStage("cssProcessing");
34306
- await generateStylesheet(this.options.config, this.options.outputDir);
34486
+ let cssChanged = true;
34487
+ if (this.cache && this.incrementalMode && this.options.config.css) {
34488
+ const cssInputPath = path9.resolve(process.cwd(), this.options.config.css.input);
34489
+ const cssOutputPath = path9.join(this.options.outputDir, this.options.config.css.output);
34490
+ const cssOutputExists = await Bun.file(cssOutputPath).exists();
34491
+ cssChanged = await hasFileChanged(cssInputPath, this.cache);
34492
+ if (!cssChanged && cssOutputExists) {
34493
+ console.log("\u23ED\uFE0F Skipping CSS (unchanged)");
34494
+ } else {
34495
+ await generateStylesheet(this.options.config, this.options.outputDir);
34496
+ await updateCacheEntry(cssInputPath, this.cache);
34497
+ }
34498
+ } else {
34499
+ await generateStylesheet(this.options.config, this.options.outputDir);
34500
+ }
34307
34501
  this.metrics.startStage("pageGeneration");
34308
34502
  await Promise.all([
34309
34503
  generateIndexPages(this.site, this.options.config, this.options.outputDir),
@@ -34320,25 +34514,75 @@ class SiteGenerator {
34320
34514
  const outputStats = await this.calculateOutputStats();
34321
34515
  const buildMetrics = this.metrics.getMetrics(outputStats);
34322
34516
  displayMetrics(buildMetrics);
34517
+ if (this.cache) {
34518
+ await saveCache(process.cwd(), this.cache);
34519
+ }
34323
34520
  }
34324
34521
  async generateFeeds() {
34325
34522
  const pageSize = 10;
34326
34523
  const rssContent = generateRSSFeed(this.site, this.options.config);
34327
- await Bun.write(path8.join(this.options.outputDir, "feed.xml"), rssContent);
34524
+ await Bun.write(path9.join(this.options.outputDir, "feed.xml"), rssContent);
34328
34525
  const sitemapContent = generateSitemap(this.site, this.options.config, pageSize);
34329
- await Bun.write(path8.join(this.options.outputDir, "sitemap.xml"), sitemapContent);
34526
+ await Bun.write(path9.join(this.options.outputDir, "sitemap.xml"), sitemapContent);
34330
34527
  console.log("Generated sitemap.xml");
34331
34528
  const urlCount = this.site.posts.length + Object.keys(this.site.tags).length + 10;
34332
34529
  const sitemapSize = sitemapContent.length;
34333
34530
  if (urlCount > 1000 || sitemapSize > 40000) {
34334
34531
  const sitemapIndexContent = generateSitemapIndex(this.options.config);
34335
- await Bun.write(path8.join(this.options.outputDir, "sitemap_index.xml"), sitemapIndexContent);
34532
+ await Bun.write(path9.join(this.options.outputDir, "sitemap_index.xml"), sitemapIndexContent);
34336
34533
  console.log("Generated sitemap_index.xml");
34337
34534
  }
34338
34535
  const robotsTxtContent = generateRobotsTxt(this.options.config);
34339
- await Bun.write(path8.join(this.options.outputDir, "robots.txt"), robotsTxtContent);
34536
+ await Bun.write(path9.join(this.options.outputDir, "robots.txt"), robotsTxtContent);
34340
34537
  console.log("Generated robots.txt");
34341
34538
  }
34539
+ async parseContent() {
34540
+ const strictMode = this.options.config.strictMode ?? false;
34541
+ if (!this.incrementalMode || !this.cache) {
34542
+ const posts = await parseMarkdownDirectory(this.options.contentDir, strictMode, this.options.config.cdn);
34543
+ if (this.cache) {
34544
+ const allFiles2 = await findFilesByPattern("**/*.md", this.options.contentDir, true);
34545
+ const postsByPath = new Map(posts.map((p) => [p.url, p]));
34546
+ for (let i = 0;i < allFiles2.length; i++) {
34547
+ const filePath = allFiles2[i];
34548
+ const post = posts[i];
34549
+ await updateCacheEntry(filePath, this.cache, { post });
34550
+ }
34551
+ }
34552
+ return posts;
34553
+ }
34554
+ const allFiles = await findFilesByPattern("**/*.md", this.options.contentDir, true);
34555
+ const configPath = path9.join(process.cwd(), "bunki.config.ts");
34556
+ const configChanged = await hasConfigChanged(configPath, this.cache);
34557
+ if (configChanged) {
34558
+ console.log("Config changed, full rebuild required");
34559
+ return this.parseContent();
34560
+ }
34561
+ const changes = await detectChanges(allFiles, this.cache);
34562
+ if (changes.fullRebuild) {
34563
+ console.log("Full rebuild required");
34564
+ this.incrementalMode = false;
34565
+ return this.parseContent();
34566
+ }
34567
+ if (changes.changedPosts.length === 0) {
34568
+ console.log("No content changes detected, using cached posts");
34569
+ const cachedPosts2 = loadCachedPosts(this.cache, allFiles);
34570
+ console.log(`\u2728 Loaded ${cachedPosts2.length} posts from cache (0ms parsing)`);
34571
+ return cachedPosts2;
34572
+ }
34573
+ const timeSaved = estimateTimeSaved(allFiles.length, changes.changedPosts.length);
34574
+ console.log(`\uD83D\uDCE6 Incremental build: ${changes.changedPosts.length}/${allFiles.length} files changed (~${timeSaved}ms saved)`);
34575
+ const changedPostsWithPaths = await parseMarkdownFiles(changes.changedPosts, this.options.config.cdn);
34576
+ const unchangedFiles = allFiles.filter((f) => !changes.changedPosts.includes(f));
34577
+ const cachedPosts = loadCachedPosts(this.cache, unchangedFiles);
34578
+ console.log(` Parsed: ${changedPostsWithPaths.length} new/changed, loaded: ${cachedPosts.length} from cache`);
34579
+ const changedPosts = changedPostsWithPaths.map((p) => p.post);
34580
+ const allPosts = [...changedPosts, ...cachedPosts].sort((a, b2) => new Date(b2.date).getTime() - new Date(a.date).getTime());
34581
+ for (const { post, filePath } of changedPostsWithPaths) {
34582
+ await updateCacheEntry(filePath, this.cache, { post });
34583
+ }
34584
+ return allPosts;
34585
+ }
34342
34586
  groupPostsByYear(posts) {
34343
34587
  const postsByYear = {};
34344
34588
  for (const post of posts) {
@@ -34387,15 +34631,18 @@ var defaultDeps2 = {
34387
34631
  };
34388
34632
  async function handleGenerateCommand(options2, deps = defaultDeps2) {
34389
34633
  try {
34390
- const configPath = path9.resolve(options2.config);
34391
- const contentDir = path9.resolve(options2.content);
34392
- const outputDir = path9.resolve(options2.output);
34393
- const templatesDir = path9.resolve(options2.templates);
34634
+ const configPath = path10.resolve(options2.config);
34635
+ const contentDir = path10.resolve(options2.content);
34636
+ const outputDir = path10.resolve(options2.output);
34637
+ const templatesDir = path10.resolve(options2.templates);
34394
34638
  deps.logger.log("Generating site with:");
34395
34639
  deps.logger.log(`- Config file: ${configPath}`);
34396
34640
  deps.logger.log(`- Content directory: ${contentDir}`);
34397
34641
  deps.logger.log(`- Output directory: ${outputDir}`);
34398
34642
  deps.logger.log(`- Templates directory: ${templatesDir}`);
34643
+ if (options2.incremental) {
34644
+ deps.logger.log(`- Incremental mode: enabled`);
34645
+ }
34399
34646
  const config = await deps.loadConfig(configPath);
34400
34647
  const generator = deps.createGenerator({
34401
34648
  contentDir,
@@ -34403,6 +34650,9 @@ async function handleGenerateCommand(options2, deps = defaultDeps2) {
34403
34650
  templatesDir,
34404
34651
  config
34405
34652
  });
34653
+ if (options2.incremental) {
34654
+ generator.enableIncrementalMode();
34655
+ }
34406
34656
  await generator.initialize();
34407
34657
  await generator.generate();
34408
34658
  deps.logger.log("Site generation completed successfully!");
@@ -34412,17 +34662,17 @@ async function handleGenerateCommand(options2, deps = defaultDeps2) {
34412
34662
  }
34413
34663
  }
34414
34664
  function registerGenerateCommand(program2) {
34415
- return program2.command("generate").description("Generate static site from markdown content").option("-c, --config <file>", "Config file path", "bunki.config.ts").option("-d, --content <dir>", "Content directory", DEFAULT_CONTENT_DIR).option("-o, --output <dir>", "Output directory", DEFAULT_OUTPUT_DIR).option("-t, --templates <dir>", "Templates directory", DEFAULT_TEMPLATES_DIR).action(async (options2) => {
34665
+ return program2.command("generate").description("Generate static site from markdown content").option("-c, --config <file>", "Config file path", "bunki.config.ts").option("-d, --content <dir>", "Content directory", DEFAULT_CONTENT_DIR).option("-o, --output <dir>", "Output directory", DEFAULT_OUTPUT_DIR).option("-t, --templates <dir>", "Templates directory", DEFAULT_TEMPLATES_DIR).option("-i, --incremental", "Enable incremental builds (only rebuild changed files)").action(async (options2) => {
34416
34666
  await handleGenerateCommand(options2);
34417
34667
  });
34418
34668
  }
34419
34669
 
34420
34670
  // src/utils/image-uploader.ts
34421
- import path11 from "path";
34671
+ import path12 from "path";
34422
34672
 
34423
34673
  // src/utils/s3-uploader.ts
34424
34674
  var {S3Client } = globalThis.Bun;
34425
- import path10 from "path";
34675
+ import path11 from "path";
34426
34676
 
34427
34677
  class S3Uploader {
34428
34678
  s3Config;
@@ -34545,8 +34795,8 @@ class S3Uploader {
34545
34795
  let failedCount = 0;
34546
34796
  const uploadTasks = imageFiles.map((imageFile) => async () => {
34547
34797
  try {
34548
- const imagePath = path10.join(imagesDir, imageFile);
34549
- const filename = path10.basename(imagePath);
34798
+ const imagePath = path11.join(imagesDir, imageFile);
34799
+ const filename = path11.basename(imagePath);
34550
34800
  const file = Bun.file(imagePath);
34551
34801
  const contentType = file.type;
34552
34802
  if (process.env.BUNKI_DRY_RUN === "true") {} else {
@@ -34580,10 +34830,10 @@ function createUploader(config) {
34580
34830
  }
34581
34831
 
34582
34832
  // src/utils/image-uploader.ts
34583
- var DEFAULT_IMAGES_DIR = path11.join(process.cwd(), "assets");
34833
+ var DEFAULT_IMAGES_DIR = path12.join(process.cwd(), "assets");
34584
34834
  async function uploadImages(options2 = {}) {
34585
34835
  try {
34586
- const imagesDir = path11.resolve(options2.images || DEFAULT_IMAGES_DIR);
34836
+ const imagesDir = path12.resolve(options2.images || DEFAULT_IMAGES_DIR);
34587
34837
  if (!await fileExists(imagesDir)) {
34588
34838
  console.log(`Creating images directory at ${imagesDir}...`);
34589
34839
  await ensureDir(imagesDir);
@@ -34620,7 +34870,7 @@ async function uploadImages(options2 = {}) {
34620
34870
  const uploader = createUploader(s3Config);
34621
34871
  const imageUrlMap = await uploader.uploadImages(imagesDir, options2.minYear);
34622
34872
  if (options2.outputJson) {
34623
- const outputFile = path11.resolve(options2.outputJson);
34873
+ const outputFile = path12.resolve(options2.outputJson);
34624
34874
  await Bun.write(outputFile, JSON.stringify(imageUrlMap, null, 2));
34625
34875
  console.log(`Image URL mapping saved to ${outputFile}`);
34626
34876
  }
@@ -34666,7 +34916,7 @@ function registerImagesPushCommand(program2) {
34666
34916
  }
34667
34917
 
34668
34918
  // src/cli/commands/init.ts
34669
- import path12 from "path";
34919
+ import path13 from "path";
34670
34920
 
34671
34921
  // src/cli/commands/templates/base-njk.ts
34672
34922
  var baseNjk = String.raw`<!DOCTYPE html>
@@ -35169,7 +35419,7 @@ var defaultDependencies = {
35169
35419
  };
35170
35420
  async function handleInitCommand(options2, deps = defaultDependencies) {
35171
35421
  try {
35172
- const configPath = path12.resolve(options2.config);
35422
+ const configPath = path13.resolve(options2.config);
35173
35423
  const configCreated = await deps.createDefaultConfig(configPath);
35174
35424
  if (!configCreated) {
35175
35425
  deps.logger.log(`
@@ -35178,19 +35428,19 @@ Skipped initialization because the config file already exists`);
35178
35428
  }
35179
35429
  deps.logger.log("Creating directory structure...");
35180
35430
  const baseDir = process.cwd();
35181
- const contentDir = path12.join(baseDir, "content");
35182
- const templatesDir = path12.join(baseDir, "templates");
35183
- const stylesDir = path12.join(templatesDir, "styles");
35184
- const publicDir = path12.join(baseDir, "public");
35431
+ const contentDir = path13.join(baseDir, "content");
35432
+ const templatesDir = path13.join(baseDir, "templates");
35433
+ const stylesDir = path13.join(templatesDir, "styles");
35434
+ const publicDir = path13.join(baseDir, "public");
35185
35435
  await deps.ensureDir(contentDir);
35186
35436
  await deps.ensureDir(templatesDir);
35187
35437
  await deps.ensureDir(stylesDir);
35188
35438
  await deps.ensureDir(publicDir);
35189
35439
  for (const [filename, content] of Object.entries(nunjucks3)) {
35190
- await deps.writeFile(path12.join(templatesDir, filename), content);
35440
+ await deps.writeFile(path13.join(templatesDir, filename), content);
35191
35441
  }
35192
- await deps.writeFile(path12.join(stylesDir, "main.css"), defaultCss);
35193
- await deps.writeFile(path12.join(contentDir, "welcome.md"), samplePost);
35442
+ await deps.writeFile(path13.join(stylesDir, "main.css"), defaultCss);
35443
+ await deps.writeFile(path13.join(contentDir, "welcome.md"), samplePost);
35194
35444
  deps.logger.log(`
35195
35445
  Initialization complete! Here are the next steps:`);
35196
35446
  deps.logger.log("1. Edit bunki.config.ts to configure your site");
@@ -35209,7 +35459,7 @@ function registerInitCommand(program2, deps = defaultDependencies) {
35209
35459
  }
35210
35460
 
35211
35461
  // src/cli/commands/new-post.ts
35212
- import path13 from "path";
35462
+ import path14 from "path";
35213
35463
  var defaultDeps4 = {
35214
35464
  writeFile: (filePath, data) => Bun.write(filePath, data),
35215
35465
  now: () => new Date,
@@ -35233,7 +35483,7 @@ async function handleNewCommand(title, options2, deps = defaultDeps4) {
35233
35483
  ` + `# ${title}
35234
35484
 
35235
35485
  `;
35236
- const filePath = path13.join(DEFAULT_CONTENT_DIR, `${slug}.md`);
35486
+ const filePath = path14.join(DEFAULT_CONTENT_DIR, `${slug}.md`);
35237
35487
  await deps.writeFile(filePath, frontmatter);
35238
35488
  deps.logger.log(`Created new post: ${filePath}`);
35239
35489
  return filePath;
@@ -35250,11 +35500,11 @@ function registerNewCommand(program2) {
35250
35500
  }
35251
35501
 
35252
35502
  // src/cli/commands/serve.ts
35253
- import path15 from "path";
35503
+ import path16 from "path";
35254
35504
 
35255
35505
  // src/server.ts
35256
35506
  import fs2 from "fs";
35257
- import path14 from "path";
35507
+ import path15 from "path";
35258
35508
  async function startServer(outputDir = DEFAULT_OUTPUT_DIR, port = 3000) {
35259
35509
  try {
35260
35510
  const stats = await fs2.promises.stat(outputDir);
@@ -35289,18 +35539,18 @@ async function startServer(outputDir = DEFAULT_OUTPUT_DIR, port = 3000) {
35289
35539
  let filePath = "";
35290
35540
  if (homePaginationMatch) {
35291
35541
  const pageNumber = homePaginationMatch[1];
35292
- filePath = path14.join(outputDir, "page", pageNumber, "index.html");
35542
+ filePath = path15.join(outputDir, "page", pageNumber, "index.html");
35293
35543
  } else if (tagPaginationMatch) {
35294
35544
  const tagSlug = tagPaginationMatch[1];
35295
35545
  const pageNumber = tagPaginationMatch[2];
35296
- filePath = path14.join(outputDir, "tags", tagSlug, "page", pageNumber, "index.html");
35546
+ filePath = path15.join(outputDir, "tags", tagSlug, "page", pageNumber, "index.html");
35297
35547
  } else if (yearPaginationMatch) {
35298
35548
  const year = yearPaginationMatch[1];
35299
35549
  const pageNumber = yearPaginationMatch[2];
35300
- filePath = path14.join(outputDir, year, "page", pageNumber, "index.html");
35550
+ filePath = path15.join(outputDir, year, "page", pageNumber, "index.html");
35301
35551
  } else {
35302
- const directPath = path14.join(outputDir, pathname);
35303
- const withoutSlash = path14.join(outputDir, pathname + ".html");
35552
+ const directPath = path15.join(outputDir, pathname);
35553
+ const withoutSlash = path15.join(outputDir, pathname + ".html");
35304
35554
  const withHtml = pathname.endsWith(".html") ? directPath : withoutSlash;
35305
35555
  const bunFileDirect = Bun.file(directPath);
35306
35556
  const bunFileHtml = Bun.file(withHtml);
@@ -35309,7 +35559,7 @@ async function startServer(outputDir = DEFAULT_OUTPUT_DIR, port = 3000) {
35309
35559
  } else if (await bunFileHtml.exists()) {
35310
35560
  filePath = withHtml;
35311
35561
  } else {
35312
- const indexPath = path14.join(outputDir, pathname, "index.html");
35562
+ const indexPath = path15.join(outputDir, pathname, "index.html");
35313
35563
  const bunFileIndex = Bun.file(indexPath);
35314
35564
  if (await bunFileIndex.exists()) {
35315
35565
  filePath = indexPath;
@@ -35323,7 +35573,7 @@ async function startServer(outputDir = DEFAULT_OUTPUT_DIR, port = 3000) {
35323
35573
  }
35324
35574
  }
35325
35575
  console.log(`Serving file: ${filePath}`);
35326
- const extname = path14.extname(filePath);
35576
+ const extname = path15.extname(filePath);
35327
35577
  let contentType = "text/html";
35328
35578
  switch (extname) {
35329
35579
  case ".js":
@@ -35382,7 +35632,7 @@ var defaultDeps5 = {
35382
35632
  };
35383
35633
  async function handleServeCommand(options2, deps = defaultDeps5) {
35384
35634
  try {
35385
- const outputDir = path15.resolve(options2.output);
35635
+ const outputDir = path16.resolve(options2.output);
35386
35636
  const port = parseInt(options2.port, 10);
35387
35637
  await deps.startServer(outputDir, port);
35388
35638
  } catch (error) {
package/dist/index.js CHANGED
@@ -30961,6 +30961,20 @@ function detectFileConflicts(files) {
30961
30961
  }
30962
30962
  return errors;
30963
30963
  }
30964
+ async function parseMarkdownFiles(filePaths, cdnConfig) {
30965
+ const resultsPromises = filePaths.map((filePath) => parseMarkdownFile(filePath, cdnConfig).then((result) => ({
30966
+ result,
30967
+ filePath
30968
+ })));
30969
+ const results = await Promise.all(resultsPromises);
30970
+ const postsWithPaths = [];
30971
+ for (const { result, filePath } of results) {
30972
+ if (result.post) {
30973
+ postsWithPaths.push({ post: result.post, filePath });
30974
+ }
30975
+ }
30976
+ return postsWithPaths;
30977
+ }
30964
30978
  async function parseMarkdownDirectory(contentDir, strictMode = false, cdnConfig) {
30965
30979
  try {
30966
30980
  const markdownFiles = await findFilesByPattern("**/*.md", contentDir, true);
@@ -31319,7 +31333,7 @@ async function startServer(outputDir = DEFAULT_OUTPUT_DIR, port = 3000) {
31319
31333
  // src/site-generator.ts
31320
31334
  var import_nunjucks2 = __toESM(require_nunjucks(), 1);
31321
31335
  var import_slugify = __toESM(require_slugify(), 1);
31322
- import path8 from "path";
31336
+ import path9 from "path";
31323
31337
 
31324
31338
  // src/utils/json-ld.ts
31325
31339
  function generateOrganizationSchema(site) {
@@ -31485,6 +31499,165 @@ function generateHomePageSchemas(options2) {
31485
31499
  return schemas;
31486
31500
  }
31487
31501
 
31502
+ // src/utils/build-cache.ts
31503
+ var {hash } = globalThis.Bun;
31504
+ import path5 from "path";
31505
+ var CACHE_VERSION = "2.0.0";
31506
+ var CACHE_FILENAME = ".bunki-cache.json";
31507
+ async function hashFile(filePath) {
31508
+ try {
31509
+ const file = Bun.file(filePath);
31510
+ const content = await file.arrayBuffer();
31511
+ return hash(content).toString(36);
31512
+ } catch (error) {
31513
+ return "";
31514
+ }
31515
+ }
31516
+ async function getFileMtime(filePath) {
31517
+ try {
31518
+ const stat = await Bun.file(filePath).stat();
31519
+ return stat?.mtime?.getTime() || 0;
31520
+ } catch (error) {
31521
+ return 0;
31522
+ }
31523
+ }
31524
+ async function loadCache(cwd) {
31525
+ const cachePath = path5.join(cwd, CACHE_FILENAME);
31526
+ const cacheFile = Bun.file(cachePath);
31527
+ try {
31528
+ if (await cacheFile.exists()) {
31529
+ const content = await cacheFile.text();
31530
+ const cache = JSON.parse(content);
31531
+ if (cache.version !== CACHE_VERSION) {
31532
+ console.log(`Cache version mismatch (${cache.version} vs ${CACHE_VERSION}), rebuilding...`);
31533
+ return createEmptyCache();
31534
+ }
31535
+ return cache;
31536
+ }
31537
+ } catch (error) {
31538
+ console.warn("Error loading cache, rebuilding:", error);
31539
+ }
31540
+ return createEmptyCache();
31541
+ }
31542
+ async function saveCache(cwd, cache) {
31543
+ const cachePath = path5.join(cwd, CACHE_FILENAME);
31544
+ try {
31545
+ await Bun.write(cachePath, JSON.stringify(cache, null, 2));
31546
+ } catch (error) {
31547
+ console.warn("Error saving cache:", error);
31548
+ }
31549
+ }
31550
+ function createEmptyCache() {
31551
+ return {
31552
+ version: CACHE_VERSION,
31553
+ files: {}
31554
+ };
31555
+ }
31556
+ async function hasFileChanged(filePath, cache) {
31557
+ const cached = cache.files[filePath];
31558
+ if (!cached) {
31559
+ return true;
31560
+ }
31561
+ const currentMtime = await getFileMtime(filePath);
31562
+ if (currentMtime !== cached.mtime) {
31563
+ const currentHash = await hashFile(filePath);
31564
+ return currentHash !== cached.hash;
31565
+ }
31566
+ return false;
31567
+ }
31568
+ async function updateCacheEntry(filePath, cache, options2) {
31569
+ const currentHash = await hashFile(filePath);
31570
+ const currentMtime = await getFileMtime(filePath);
31571
+ cache.files[filePath] = {
31572
+ hash: currentHash,
31573
+ mtime: currentMtime,
31574
+ post: options2?.post,
31575
+ outputs: options2?.outputs
31576
+ };
31577
+ }
31578
+ async function hasConfigChanged(configPath, cache) {
31579
+ const currentHash = await hashFile(configPath);
31580
+ if (!cache.configHash) {
31581
+ cache.configHash = currentHash;
31582
+ return true;
31583
+ }
31584
+ if (currentHash !== cache.configHash) {
31585
+ cache.configHash = currentHash;
31586
+ return true;
31587
+ }
31588
+ return false;
31589
+ }
31590
+ function loadCachedPosts(cache, filePaths) {
31591
+ const posts = [];
31592
+ for (const filePath of filePaths) {
31593
+ const entry = cache.files[filePath];
31594
+ if (entry?.post) {
31595
+ posts.push(entry.post);
31596
+ }
31597
+ }
31598
+ return posts;
31599
+ }
31600
+
31601
+ // src/utils/change-detector.ts
31602
+ async function detectChanges(currentFiles, cache, options2 = {}) {
31603
+ const changes = {
31604
+ changedPosts: [],
31605
+ deletedPosts: [],
31606
+ stylesChanged: false,
31607
+ configChanged: false,
31608
+ templatesChanged: false,
31609
+ fullRebuild: false
31610
+ };
31611
+ if (options2.configPath) {
31612
+ const configChanged = await hasFileChanged(options2.configPath, cache);
31613
+ if (configChanged) {
31614
+ changes.configChanged = true;
31615
+ changes.fullRebuild = true;
31616
+ return changes;
31617
+ }
31618
+ }
31619
+ if (options2.templatePaths && options2.templatePaths.length > 0) {
31620
+ for (const templatePath of options2.templatePaths) {
31621
+ const changed = await hasFileChanged(templatePath, cache);
31622
+ if (changed) {
31623
+ changes.templatesChanged = true;
31624
+ changes.fullRebuild = true;
31625
+ return changes;
31626
+ }
31627
+ }
31628
+ }
31629
+ if (options2.stylesPaths && options2.stylesPaths.length > 0) {
31630
+ for (const stylePath of options2.stylesPaths) {
31631
+ const changed = await hasFileChanged(stylePath, cache);
31632
+ if (changed) {
31633
+ changes.stylesChanged = true;
31634
+ break;
31635
+ }
31636
+ }
31637
+ }
31638
+ for (const filePath of currentFiles) {
31639
+ const changed = await hasFileChanged(filePath, cache);
31640
+ if (changed) {
31641
+ changes.changedPosts.push(filePath);
31642
+ }
31643
+ }
31644
+ const cachedFiles = Object.keys(cache.files).filter((f) => f.endsWith(".md"));
31645
+ for (const cachedFile of cachedFiles) {
31646
+ if (!currentFiles.includes(cachedFile)) {
31647
+ changes.deletedPosts.push(cachedFile);
31648
+ }
31649
+ }
31650
+ if (changes.deletedPosts.length > 0) {
31651
+ changes.fullRebuild = true;
31652
+ }
31653
+ return changes;
31654
+ }
31655
+ function estimateTimeSaved(totalPosts, changedPosts) {
31656
+ const avgTimePerPost = 6;
31657
+ const skippedPosts = totalPosts - changedPosts;
31658
+ return skippedPosts * avgTimePerPost;
31659
+ }
31660
+
31488
31661
  // src/utils/xml-builder.ts
31489
31662
  function escapeXml(text) {
31490
31663
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
@@ -31717,14 +31890,14 @@ Sitemap: ${config.baseUrl}/sitemap.xml
31717
31890
 
31718
31891
  // src/generators/pages.ts
31719
31892
  var import_nunjucks = __toESM(require_nunjucks(), 1);
31720
- import path5 from "path";
31893
+ import path6 from "path";
31721
31894
  function getSortedTags(tags, limit) {
31722
31895
  const sorted = Object.values(tags).sort((a, b2) => b2.count - a.count);
31723
31896
  return limit ? sorted.slice(0, limit) : sorted;
31724
31897
  }
31725
31898
  async function writeHtmlFile(outputDir, relativePath, content) {
31726
- const fullPath = path5.join(outputDir, relativePath);
31727
- const dir = path5.dirname(fullPath);
31899
+ const fullPath = path6.join(outputDir, relativePath);
31900
+ const dir = path6.dirname(fullPath);
31728
31901
  await ensureDir(dir);
31729
31902
  await Bun.write(fullPath, content);
31730
31903
  }
@@ -31900,12 +32073,12 @@ async function generateMapPage(site, config, outputDir) {
31900
32073
 
31901
32074
  // src/generators/assets.ts
31902
32075
  var {Glob: Glob2 } = globalThis.Bun;
31903
- import path7 from "path";
32076
+ import path8 from "path";
31904
32077
 
31905
32078
  // src/utils/css-processor.ts
31906
32079
  import { spawn } from "child_process";
31907
- var {hash } = globalThis.Bun;
31908
- import path6 from "path";
32080
+ var {hash: hash2 } = globalThis.Bun;
32081
+ import path7 from "path";
31909
32082
  async function processCSS(options2) {
31910
32083
  const {
31911
32084
  css,
@@ -31920,14 +32093,14 @@ async function processCSS(options2) {
31920
32093
  }
31921
32094
  return { outputPath: "" };
31922
32095
  }
31923
- const inputPath = path6.resolve(projectRoot, css.input);
31924
- const tempOutputPath = path6.resolve(outputDir, css.output);
31925
- const postcssConfigPath = css.postcssConfig ? path6.resolve(projectRoot, css.postcssConfig) : path6.resolve(projectRoot, "postcss.config.js");
32096
+ const inputPath = path7.resolve(projectRoot, css.input);
32097
+ const tempOutputPath = path7.resolve(outputDir, css.output);
32098
+ const postcssConfigPath = css.postcssConfig ? path7.resolve(projectRoot, css.postcssConfig) : path7.resolve(projectRoot, "postcss.config.js");
31926
32099
  const inputFile = Bun.file(inputPath);
31927
32100
  if (!await inputFile.exists()) {
31928
32101
  throw new Error(`CSS input file not found: ${inputPath}`);
31929
32102
  }
31930
- const outputDirPath = path6.dirname(tempOutputPath);
32103
+ const outputDirPath = path7.dirname(tempOutputPath);
31931
32104
  await ensureDir(outputDirPath);
31932
32105
  if (verbose) {
31933
32106
  console.log("\uD83C\uDFA8 Building CSS with PostCSS...");
@@ -31939,12 +32112,12 @@ async function processCSS(options2) {
31939
32112
  if (enableHashing) {
31940
32113
  const cssFile = Bun.file(tempOutputPath);
31941
32114
  const cssContent = await cssFile.arrayBuffer();
31942
- const contentHash = hash(cssContent).toString(36).slice(0, 8);
31943
- const ext = path6.extname(tempOutputPath);
31944
- const basename = path6.basename(tempOutputPath, ext);
31945
- const dir = path6.dirname(tempOutputPath);
32115
+ const contentHash = hash2(cssContent).toString(36).slice(0, 8);
32116
+ const ext = path7.extname(tempOutputPath);
32117
+ const basename = path7.basename(tempOutputPath, ext);
32118
+ const dir = path7.dirname(tempOutputPath);
31946
32119
  const hashedFilename = `${basename}.${contentHash}${ext}`;
31947
- const hashedOutputPath = path6.join(dir, hashedFilename);
32120
+ const hashedOutputPath = path7.join(dir, hashedFilename);
31948
32121
  await Bun.write(hashedOutputPath, cssFile);
31949
32122
  if (verbose) {
31950
32123
  console.log(`\u2705 CSS hashed: ${hashedFilename}`);
@@ -32020,15 +32193,15 @@ async function generateStylesheet(config, outputDir) {
32020
32193
  }
32021
32194
  }
32022
32195
  async function fallbackCSSGeneration(cssConfig, outputDir) {
32023
- const cssFilePath = path7.resolve(process.cwd(), cssConfig.input);
32196
+ const cssFilePath = path8.resolve(process.cwd(), cssConfig.input);
32024
32197
  const cssFile = Bun.file(cssFilePath);
32025
32198
  if (!await cssFile.exists()) {
32026
32199
  console.warn(`CSS input file not found: ${cssFilePath}`);
32027
32200
  return;
32028
32201
  }
32029
32202
  try {
32030
- const outputPath = path7.resolve(outputDir, cssConfig.output);
32031
- const outputDirPath = path7.dirname(outputPath);
32203
+ const outputPath = path8.resolve(outputDir, cssConfig.output);
32204
+ const outputDirPath = path8.dirname(outputPath);
32032
32205
  await ensureDir(outputDirPath);
32033
32206
  await Bun.write(outputPath, cssFile);
32034
32207
  console.log("\u2705 CSS file copied successfully (fallback mode)");
@@ -32037,19 +32210,19 @@ async function fallbackCSSGeneration(cssConfig, outputDir) {
32037
32210
  }
32038
32211
  }
32039
32212
  async function copyStaticAssets(templatesDir, outputDir) {
32040
- const assetsDir = path7.join(templatesDir, "assets");
32041
- const publicDir = path7.join(process.cwd(), "public");
32213
+ const assetsDir = path8.join(templatesDir, "assets");
32214
+ const publicDir = path8.join(process.cwd(), "public");
32042
32215
  if (await isDirectory(assetsDir)) {
32043
32216
  const assetGlob = new Glob2("**/*.*");
32044
- const assetsOutputDir = path7.join(outputDir, "assets");
32217
+ const assetsOutputDir = path8.join(outputDir, "assets");
32045
32218
  await ensureDir(assetsOutputDir);
32046
32219
  for await (const file of assetGlob.scan({
32047
32220
  cwd: assetsDir,
32048
32221
  absolute: true
32049
32222
  })) {
32050
- const relativePath = path7.relative(assetsDir, file);
32051
- const targetPath = path7.join(assetsOutputDir, relativePath);
32052
- const targetDir = path7.dirname(targetPath);
32223
+ const relativePath = path8.relative(assetsDir, file);
32224
+ const targetPath = path8.join(assetsOutputDir, relativePath);
32225
+ const targetDir = path8.dirname(targetPath);
32053
32226
  await ensureDir(targetDir);
32054
32227
  await copyFile(file, targetPath);
32055
32228
  }
@@ -32063,9 +32236,9 @@ async function copyStaticAssets(templatesDir, outputDir) {
32063
32236
  })) {
32064
32237
  if (await isDirectory(file))
32065
32238
  continue;
32066
- const relativePath = path7.relative(publicDir, file);
32067
- const destPath = path7.join(outputDir, relativePath);
32068
- const targetDir = path7.dirname(destPath);
32239
+ const relativePath = path8.relative(publicDir, file);
32240
+ const destPath = path8.join(outputDir, relativePath);
32241
+ const targetDir = path8.dirname(destPath);
32069
32242
  await ensureDir(targetDir);
32070
32243
  await copyFile(file, destPath);
32071
32244
  }
@@ -32147,6 +32320,8 @@ class SiteGenerator {
32147
32320
  options;
32148
32321
  site;
32149
32322
  metrics;
32323
+ cache = null;
32324
+ incrementalMode = false;
32150
32325
  constructor(options2) {
32151
32326
  this.options = options2;
32152
32327
  this.site = {
@@ -32192,6 +32367,9 @@ class SiteGenerator {
32192
32367
  }
32193
32368
  });
32194
32369
  }
32370
+ enableIncrementalMode() {
32371
+ this.incrementalMode = true;
32372
+ }
32195
32373
  async initialize() {
32196
32374
  this.metrics.startStage("initialization");
32197
32375
  console.log("Initializing site generator...");
@@ -32200,7 +32378,7 @@ class SiteGenerator {
32200
32378
  setNoFollowExceptions(this.options.config.noFollowExceptions);
32201
32379
  }
32202
32380
  let tagDescriptions = {};
32203
- const tagsTomlPath = path8.join(process.cwd(), "src", "tags.toml");
32381
+ const tagsTomlPath = path9.join(process.cwd(), "src", "tags.toml");
32204
32382
  const tagsTomlFile = Bun.file(tagsTomlPath);
32205
32383
  if (await tagsTomlFile.exists()) {
32206
32384
  try {
@@ -32210,8 +32388,10 @@ class SiteGenerator {
32210
32388
  console.warn("Error loading tag descriptions:", error);
32211
32389
  }
32212
32390
  }
32213
- const strictMode = this.options.config.strictMode ?? false;
32214
- const posts = await parseMarkdownDirectory(this.options.contentDir, strictMode, this.options.config.cdn);
32391
+ if (this.incrementalMode) {
32392
+ this.cache = await loadCache(process.cwd());
32393
+ }
32394
+ const posts = await this.parseContent();
32215
32395
  const tags = {};
32216
32396
  posts.forEach((post) => {
32217
32397
  post.tagSlugs = {};
@@ -32249,7 +32429,21 @@ class SiteGenerator {
32249
32429
  console.log("Generating static site...");
32250
32430
  await ensureDir(this.options.outputDir);
32251
32431
  this.metrics.startStage("cssProcessing");
32252
- await generateStylesheet(this.options.config, this.options.outputDir);
32432
+ let cssChanged = true;
32433
+ if (this.cache && this.incrementalMode && this.options.config.css) {
32434
+ const cssInputPath = path9.resolve(process.cwd(), this.options.config.css.input);
32435
+ const cssOutputPath = path9.join(this.options.outputDir, this.options.config.css.output);
32436
+ const cssOutputExists = await Bun.file(cssOutputPath).exists();
32437
+ cssChanged = await hasFileChanged(cssInputPath, this.cache);
32438
+ if (!cssChanged && cssOutputExists) {
32439
+ console.log("\u23ED\uFE0F Skipping CSS (unchanged)");
32440
+ } else {
32441
+ await generateStylesheet(this.options.config, this.options.outputDir);
32442
+ await updateCacheEntry(cssInputPath, this.cache);
32443
+ }
32444
+ } else {
32445
+ await generateStylesheet(this.options.config, this.options.outputDir);
32446
+ }
32253
32447
  this.metrics.startStage("pageGeneration");
32254
32448
  await Promise.all([
32255
32449
  generateIndexPages(this.site, this.options.config, this.options.outputDir),
@@ -32266,25 +32460,75 @@ class SiteGenerator {
32266
32460
  const outputStats = await this.calculateOutputStats();
32267
32461
  const buildMetrics = this.metrics.getMetrics(outputStats);
32268
32462
  displayMetrics(buildMetrics);
32463
+ if (this.cache) {
32464
+ await saveCache(process.cwd(), this.cache);
32465
+ }
32269
32466
  }
32270
32467
  async generateFeeds() {
32271
32468
  const pageSize = 10;
32272
32469
  const rssContent = generateRSSFeed(this.site, this.options.config);
32273
- await Bun.write(path8.join(this.options.outputDir, "feed.xml"), rssContent);
32470
+ await Bun.write(path9.join(this.options.outputDir, "feed.xml"), rssContent);
32274
32471
  const sitemapContent = generateSitemap(this.site, this.options.config, pageSize);
32275
- await Bun.write(path8.join(this.options.outputDir, "sitemap.xml"), sitemapContent);
32472
+ await Bun.write(path9.join(this.options.outputDir, "sitemap.xml"), sitemapContent);
32276
32473
  console.log("Generated sitemap.xml");
32277
32474
  const urlCount = this.site.posts.length + Object.keys(this.site.tags).length + 10;
32278
32475
  const sitemapSize = sitemapContent.length;
32279
32476
  if (urlCount > 1000 || sitemapSize > 40000) {
32280
32477
  const sitemapIndexContent = generateSitemapIndex(this.options.config);
32281
- await Bun.write(path8.join(this.options.outputDir, "sitemap_index.xml"), sitemapIndexContent);
32478
+ await Bun.write(path9.join(this.options.outputDir, "sitemap_index.xml"), sitemapIndexContent);
32282
32479
  console.log("Generated sitemap_index.xml");
32283
32480
  }
32284
32481
  const robotsTxtContent = generateRobotsTxt(this.options.config);
32285
- await Bun.write(path8.join(this.options.outputDir, "robots.txt"), robotsTxtContent);
32482
+ await Bun.write(path9.join(this.options.outputDir, "robots.txt"), robotsTxtContent);
32286
32483
  console.log("Generated robots.txt");
32287
32484
  }
32485
+ async parseContent() {
32486
+ const strictMode = this.options.config.strictMode ?? false;
32487
+ if (!this.incrementalMode || !this.cache) {
32488
+ const posts = await parseMarkdownDirectory(this.options.contentDir, strictMode, this.options.config.cdn);
32489
+ if (this.cache) {
32490
+ const allFiles2 = await findFilesByPattern("**/*.md", this.options.contentDir, true);
32491
+ const postsByPath = new Map(posts.map((p) => [p.url, p]));
32492
+ for (let i = 0;i < allFiles2.length; i++) {
32493
+ const filePath = allFiles2[i];
32494
+ const post = posts[i];
32495
+ await updateCacheEntry(filePath, this.cache, { post });
32496
+ }
32497
+ }
32498
+ return posts;
32499
+ }
32500
+ const allFiles = await findFilesByPattern("**/*.md", this.options.contentDir, true);
32501
+ const configPath = path9.join(process.cwd(), "bunki.config.ts");
32502
+ const configChanged = await hasConfigChanged(configPath, this.cache);
32503
+ if (configChanged) {
32504
+ console.log("Config changed, full rebuild required");
32505
+ return this.parseContent();
32506
+ }
32507
+ const changes = await detectChanges(allFiles, this.cache);
32508
+ if (changes.fullRebuild) {
32509
+ console.log("Full rebuild required");
32510
+ this.incrementalMode = false;
32511
+ return this.parseContent();
32512
+ }
32513
+ if (changes.changedPosts.length === 0) {
32514
+ console.log("No content changes detected, using cached posts");
32515
+ const cachedPosts2 = loadCachedPosts(this.cache, allFiles);
32516
+ console.log(`\u2728 Loaded ${cachedPosts2.length} posts from cache (0ms parsing)`);
32517
+ return cachedPosts2;
32518
+ }
32519
+ const timeSaved = estimateTimeSaved(allFiles.length, changes.changedPosts.length);
32520
+ console.log(`\uD83D\uDCE6 Incremental build: ${changes.changedPosts.length}/${allFiles.length} files changed (~${timeSaved}ms saved)`);
32521
+ const changedPostsWithPaths = await parseMarkdownFiles(changes.changedPosts, this.options.config.cdn);
32522
+ const unchangedFiles = allFiles.filter((f) => !changes.changedPosts.includes(f));
32523
+ const cachedPosts = loadCachedPosts(this.cache, unchangedFiles);
32524
+ console.log(` Parsed: ${changedPostsWithPaths.length} new/changed, loaded: ${cachedPosts.length} from cache`);
32525
+ const changedPosts = changedPostsWithPaths.map((p) => p.post);
32526
+ const allPosts = [...changedPosts, ...cachedPosts].sort((a, b2) => new Date(b2.date).getTime() - new Date(a.date).getTime());
32527
+ for (const { post, filePath } of changedPostsWithPaths) {
32528
+ await updateCacheEntry(filePath, this.cache, { post });
32529
+ }
32530
+ return allPosts;
32531
+ }
32288
32532
  groupPostsByYear(posts) {
32289
32533
  const postsByYear = {};
32290
32534
  for (const post of posts) {
@@ -32324,11 +32568,11 @@ class SiteGenerator {
32324
32568
  }
32325
32569
  }
32326
32570
  // src/utils/image-uploader.ts
32327
- import path10 from "path";
32571
+ import path11 from "path";
32328
32572
 
32329
32573
  // src/utils/s3-uploader.ts
32330
32574
  var {S3Client } = globalThis.Bun;
32331
- import path9 from "path";
32575
+ import path10 from "path";
32332
32576
 
32333
32577
  class S3Uploader {
32334
32578
  s3Config;
@@ -32451,8 +32695,8 @@ class S3Uploader {
32451
32695
  let failedCount = 0;
32452
32696
  const uploadTasks = imageFiles.map((imageFile) => async () => {
32453
32697
  try {
32454
- const imagePath = path9.join(imagesDir, imageFile);
32455
- const filename = path9.basename(imagePath);
32698
+ const imagePath = path10.join(imagesDir, imageFile);
32699
+ const filename = path10.basename(imagePath);
32456
32700
  const file = Bun.file(imagePath);
32457
32701
  const contentType = file.type;
32458
32702
  if (process.env.BUNKI_DRY_RUN === "true") {} else {
@@ -32486,10 +32730,10 @@ function createUploader(config) {
32486
32730
  }
32487
32731
 
32488
32732
  // src/utils/image-uploader.ts
32489
- var DEFAULT_IMAGES_DIR = path10.join(process.cwd(), "assets");
32733
+ var DEFAULT_IMAGES_DIR = path11.join(process.cwd(), "assets");
32490
32734
  async function uploadImages(options2 = {}) {
32491
32735
  try {
32492
- const imagesDir = path10.resolve(options2.images || DEFAULT_IMAGES_DIR);
32736
+ const imagesDir = path11.resolve(options2.images || DEFAULT_IMAGES_DIR);
32493
32737
  if (!await fileExists(imagesDir)) {
32494
32738
  console.log(`Creating images directory at ${imagesDir}...`);
32495
32739
  await ensureDir(imagesDir);
@@ -32526,7 +32770,7 @@ async function uploadImages(options2 = {}) {
32526
32770
  const uploader = createUploader(s3Config);
32527
32771
  const imageUrlMap = await uploader.uploadImages(imagesDir, options2.minYear);
32528
32772
  if (options2.outputJson) {
32529
- const outputFile = path10.resolve(options2.outputJson);
32773
+ const outputFile = path11.resolve(options2.outputJson);
32530
32774
  await Bun.write(outputFile, JSON.stringify(imageUrlMap, null, 2));
32531
32775
  console.log(`Image URL mapping saved to ${outputFile}`);
32532
32776
  }
package/dist/parser.d.ts CHANGED
@@ -4,4 +4,12 @@ export interface ParseResult {
4
4
  posts: Post[];
5
5
  errors: ParseError[];
6
6
  }
7
+ /**
8
+ * Parse specific markdown files (for incremental builds)
9
+ * Returns both posts and their file paths for cache updates
10
+ */
11
+ export declare function parseMarkdownFiles(filePaths: string[], cdnConfig?: CDNConfig): Promise<Array<{
12
+ post: Post;
13
+ filePath: string;
14
+ }>>;
7
15
  export declare function parseMarkdownDirectory(contentDir: string, strictMode?: boolean, cdnConfig?: CDNConfig): Promise<Post[]>;
@@ -7,7 +7,13 @@ export declare class SiteGenerator {
7
7
  private options;
8
8
  private site;
9
9
  private metrics;
10
+ private cache;
11
+ private incrementalMode;
10
12
  constructor(options: GeneratorOptions);
13
+ /**
14
+ * Enable incremental builds
15
+ */
16
+ enableIncrementalMode(): void;
11
17
  /**
12
18
  * Initialize site data - parse markdown and prepare site structure
13
19
  */
@@ -20,6 +26,10 @@ export declare class SiteGenerator {
20
26
  * Generate all feed files (RSS, sitemap, robots.txt)
21
27
  */
22
28
  private generateFeeds;
29
+ /**
30
+ * Parse content (full or incremental)
31
+ */
32
+ private parseContent;
23
33
  /**
24
34
  * Group posts by year (Pacific timezone)
25
35
  * @param posts - Array of posts
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Build cache for incremental builds
3
+ * Tracks file hashes, modification times, parsed post data, and build outputs
4
+ */
5
+ import type { Post } from "../types";
6
+ export interface CacheEntry {
7
+ /** Content hash of the file */
8
+ hash: string;
9
+ /** Last modification time (ms since epoch) */
10
+ mtime: number;
11
+ /** Cached parsed post data */
12
+ post?: Post;
13
+ /** Generated output files */
14
+ outputs?: string[];
15
+ }
16
+ export interface BuildCache {
17
+ /** Version of cache format */
18
+ version: string;
19
+ /** File cache entries */
20
+ files: Record<string, CacheEntry>;
21
+ /** Config file hash */
22
+ configHash?: string;
23
+ /** Last full build timestamp */
24
+ lastFullBuild?: number;
25
+ }
26
+ /**
27
+ * Calculate content hash for a file
28
+ */
29
+ export declare function hashFile(filePath: string): Promise<string>;
30
+ /**
31
+ * Get file modification time
32
+ */
33
+ export declare function getFileMtime(filePath: string): Promise<number>;
34
+ /**
35
+ * Load build cache from disk
36
+ */
37
+ export declare function loadCache(cwd: string): Promise<BuildCache>;
38
+ /**
39
+ * Save build cache to disk
40
+ */
41
+ export declare function saveCache(cwd: string, cache: BuildCache): Promise<void>;
42
+ /**
43
+ * Create empty build cache
44
+ */
45
+ export declare function createEmptyCache(): BuildCache;
46
+ /**
47
+ * Check if a file has changed since last build
48
+ */
49
+ export declare function hasFileChanged(filePath: string, cache: BuildCache): Promise<boolean>;
50
+ /**
51
+ * Update cache entry for a file
52
+ */
53
+ export declare function updateCacheEntry(filePath: string, cache: BuildCache, options?: {
54
+ post?: Post;
55
+ outputs?: string[];
56
+ }): Promise<void>;
57
+ /**
58
+ * Remove cache entry for a file
59
+ */
60
+ export declare function removeCacheEntry(filePath: string, cache: BuildCache): void;
61
+ /**
62
+ * Check if config file has changed
63
+ */
64
+ export declare function hasConfigChanged(configPath: string, cache: BuildCache): Promise<boolean>;
65
+ /**
66
+ * Mark full build timestamp
67
+ */
68
+ export declare function markFullBuild(cache: BuildCache): void;
69
+ /**
70
+ * Check if full rebuild is needed
71
+ */
72
+ export declare function needsFullRebuild(cache: BuildCache, maxAge: number): boolean;
73
+ /**
74
+ * Load cached posts for files that haven't changed
75
+ */
76
+ export declare function loadCachedPosts(cache: BuildCache, filePaths: string[]): Post[];
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Change detection for incremental builds
3
+ * Determines which files have changed and what needs to be rebuilt
4
+ */
5
+ import type { BuildCache } from "./build-cache";
6
+ import type { Post } from "../types";
7
+ export interface ChangeSet {
8
+ /** Posts that were added or modified */
9
+ changedPosts: string[];
10
+ /** Posts that were deleted */
11
+ deletedPosts: string[];
12
+ /** Whether any CSS/style files changed */
13
+ stylesChanged: boolean;
14
+ /** Whether config file changed */
15
+ configChanged: boolean;
16
+ /** Whether template files changed */
17
+ templatesChanged: boolean;
18
+ /** Whether a full rebuild is required */
19
+ fullRebuild: boolean;
20
+ }
21
+ /**
22
+ * Detect changes since last build
23
+ */
24
+ export declare function detectChanges(currentFiles: string[], cache: BuildCache, options?: {
25
+ configPath?: string;
26
+ stylesPaths?: string[];
27
+ templatePaths?: string[];
28
+ }): Promise<ChangeSet>;
29
+ /**
30
+ * Determine affected tags from changed posts
31
+ */
32
+ export declare function getAffectedTags(changedPosts: Post[], allPosts: Post[]): Set<string>;
33
+ /**
34
+ * Check if index pages need regeneration
35
+ */
36
+ export declare function needsIndexRegeneration(changes: ChangeSet): boolean;
37
+ /**
38
+ * Estimate time saved by incremental build
39
+ */
40
+ export declare function estimateTimeSaved(totalPosts: number, changedPosts: number): number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunki",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "description": "An opinionated static site generator built with Bun featuring PostCSS integration and modern web development workflows",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",