bunki 0.14.1 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -96,6 +96,34 @@ performance = "Performance optimization and speed"
96
96
  web = "Web development and technology"
97
97
  ```
98
98
 
99
+ ### Business Location Data
100
+
101
+ Add structured business/location data with automatic validation:
102
+
103
+ ```markdown
104
+ ---
105
+ title: "Restaurant Review"
106
+ date: 2025-01-15T09:00:00-07:00
107
+ tags: [food, review]
108
+ business:
109
+ - type: Restaurant
110
+ name: "Blue Bottle Coffee"
111
+ address: "123 Main St, San Francisco, CA 94102"
112
+ lat: 37.7749
113
+ lng: -122.4194
114
+ ---
115
+ ```
116
+
117
+ **Required fields:** `type`, `name`, `lat`, `lng`
118
+ **Optional fields:** `address`, `cuisine`, `priceRange`, `telephone`, `url`, `openingHours`
119
+
120
+ The validator enforces:
121
+ - Use `business:` (not deprecated `location:`)
122
+ - Use `lat:`/`lng:` (not deprecated `latitude:`/`longitude:`)
123
+ - All required fields must be present
124
+
125
+ Validation runs automatically during `bunki generate` and `bunki validate`.
126
+
99
127
  ## CSS & Tailwind
100
128
 
101
129
  To use Tailwind CSS:
@@ -718,6 +746,7 @@ export S3_PUBLIC_URL="https://img.example.com"
718
746
  bunki init [--config FILE] # Initialize new site
719
747
  bunki new <TITLE> [--tags TAG1,TAG2] # Create new post
720
748
  bunki generate [--config FILE] # Build static site
749
+ bunki validate [--config FILE] # Validate frontmatter
721
750
  bunki serve [--port 3000] # Start dev server
722
751
  bunki css [--watch] # Process CSS
723
752
  bunki images:push [--domain DOMAIN] # Upload images to cloud
@@ -744,6 +773,7 @@ dist/
744
773
  ## Features
745
774
 
746
775
  - **Markdown Processing**: Frontmatter extraction, code highlighting, HTML sanitization
776
+ - **Frontmatter Validation**: Automatic validation of business location data with clear error messages
747
777
  - **Security**: XSS protection, sanitized HTML, link hardening
748
778
  - **Performance**: Static files, optional gzip, optimized output
749
779
  - **Templating**: Nunjucks with custom filters and macros
@@ -789,7 +819,18 @@ bunki/
789
819
 
790
820
  ## Changelog
791
821
 
792
- ### v0.8.0 (Current)
822
+ ### v0.15.0 (Current)
823
+
824
+ - **Frontmatter Validation**: Automatic validation of business location data
825
+ - Enforces `business:` field (rejects deprecated `location:`)
826
+ - Enforces `lat:`/`lng:` coordinates (rejects deprecated `latitude:`/`longitude:`)
827
+ - Validates required fields (type, name, lat, lng)
828
+ - Clear error messages with suggestions for fixes
829
+ - New `bunki validate` command for standalone validation
830
+ - **Enhanced Testing**: 47 tests for markdown parsing and validation
831
+ - **Breaking Change**: Deprecated `location:`, `latitude:`, and `longitude:` fields now rejected
832
+
833
+ ### v0.8.0
793
834
 
794
835
  - **JSON-LD Structured Data**: Automatic Schema.org markup generation for enhanced SEO
795
836
  - BlogPosting schema for individual blog posts with author, keywords, images
package/dist/cli.js CHANGED
@@ -32902,7 +32902,15 @@ core_default.registerLanguage("python", python);
32902
32902
  core_default.registerLanguage("json", json);
32903
32903
  core_default.registerLanguage("swift", swift);
32904
32904
  var noFollowExceptions = new Set;
32905
- function createMarked() {
32905
+ function transformImagePath(relativePath, config) {
32906
+ const match = relativePath.match(/^\.\.\/\.\.\/assets\/(\d{4})\/([^/]+)\/(.+)$/);
32907
+ if (!match)
32908
+ return null;
32909
+ const [, year, slug, filename] = match;
32910
+ const path5 = config.pathPattern.replace("{year}", year).replace("{slug}", slug).replace("{filename}", filename);
32911
+ return `${config.baseUrl}/${path5}`;
32912
+ }
32913
+ function createMarked(cdnConfig) {
32906
32914
  const marked = new B(markedHighlight({
32907
32915
  emptyLangClass: "hljs",
32908
32916
  langPrefix: "hljs language-",
@@ -32951,6 +32959,15 @@ function createMarked() {
32951
32959
  }
32952
32960
  }
32953
32961
  }
32962
+ if (token.type === "image" && cdnConfig?.enabled) {
32963
+ const href = token.href || "";
32964
+ if (href.startsWith("../../assets/")) {
32965
+ const transformed = transformImagePath(href, cdnConfig);
32966
+ if (transformed) {
32967
+ token.href = transformed;
32968
+ }
32969
+ }
32970
+ }
32954
32971
  },
32955
32972
  hooks: {
32956
32973
  preprocess(markdown2) {
@@ -32993,8 +33010,9 @@ function extractExcerpt(content, maxLength = 200) {
32993
33010
  const lastSpace = truncated.lastIndexOf(" ");
32994
33011
  return truncated.substring(0, lastSpace) + "...";
32995
33012
  }
32996
- function convertMarkdownToHtml(markdownContent) {
32997
- const html = marked.parse(markdownContent, { async: false });
33013
+ function convertMarkdownToHtml(markdownContent, cdnConfig) {
33014
+ const markedInstance = cdnConfig ? createMarked(cdnConfig) : marked;
33015
+ const html = markedInstance.parse(markdownContent, { async: false });
32998
33016
  let sanitized = import_sanitize_html.default(html, {
32999
33017
  allowedTags: import_sanitize_html.default.defaults.allowedTags.concat([
33000
33018
  "img",
@@ -33060,7 +33078,93 @@ function convertMarkdownToHtml(markdownContent) {
33060
33078
  sanitized = sanitized.replace(/javascript:/gi, "").replace(/vbscript:/gi, "");
33061
33079
  return sanitized;
33062
33080
  }
33063
- async function parseMarkdownFile(filePath) {
33081
+ function validateBusinessLocation(business, filePath) {
33082
+ if (!business)
33083
+ return null;
33084
+ const locations = Array.isArray(business) ? business : [business];
33085
+ for (let i = 0;i < locations.length; i++) {
33086
+ const loc = locations[i];
33087
+ const locIndex = locations.length > 1 ? ` (location ${i + 1})` : "";
33088
+ if (!loc.type) {
33089
+ return {
33090
+ file: filePath,
33091
+ type: "validation",
33092
+ message: `Missing required field 'type' in business${locIndex}`,
33093
+ suggestion: "Add 'type: Restaurant' (or Market, Park, Hotel, Museum, Cafe, Zoo, etc.) to frontmatter"
33094
+ };
33095
+ }
33096
+ const validTypes = [
33097
+ "Accommodation",
33098
+ "Apartment",
33099
+ "Attraction",
33100
+ "Beach",
33101
+ "BodyOfWater",
33102
+ "Bridge",
33103
+ "Building",
33104
+ "BusStation",
33105
+ "Cafe",
33106
+ "Campground",
33107
+ "CivicStructure",
33108
+ "EventVenue",
33109
+ "Ferry",
33110
+ "Garden",
33111
+ "HistoricalSite",
33112
+ "Hotel",
33113
+ "Hostel",
33114
+ "Landmark",
33115
+ "LodgingBusiness",
33116
+ "Market",
33117
+ "Monument",
33118
+ "Museum",
33119
+ "NaturalFeature",
33120
+ "Park",
33121
+ "Playground",
33122
+ "Restaurant",
33123
+ "ServiceCenter",
33124
+ "ShoppingCenter",
33125
+ "Store",
33126
+ "TouristAttraction",
33127
+ "TrainStation",
33128
+ "Viewpoint",
33129
+ "Zoo"
33130
+ ];
33131
+ if (!validTypes.includes(loc.type)) {
33132
+ return {
33133
+ file: filePath,
33134
+ type: "validation",
33135
+ message: `Invalid business type '${loc.type}' in business${locIndex}`,
33136
+ suggestion: `Use a valid Schema.org Place type: ${validTypes.slice(0, 10).join(", ")}, etc.`
33137
+ };
33138
+ }
33139
+ if (!loc.name) {
33140
+ return {
33141
+ file: filePath,
33142
+ type: "validation",
33143
+ message: `Missing required field 'name' in business${locIndex}`,
33144
+ suggestion: `Add 'name: "Full Business Name"' to frontmatter`
33145
+ };
33146
+ }
33147
+ if (loc.latitude !== undefined || loc.longitude !== undefined) {
33148
+ return {
33149
+ file: filePath,
33150
+ type: "validation",
33151
+ message: `Use 'lat' and 'lng' instead of 'latitude' and 'longitude' in business${locIndex}`,
33152
+ suggestion: "Replace 'latitude:' with 'lat:' and 'longitude:' with 'lng:' in frontmatter"
33153
+ };
33154
+ }
33155
+ const hasLatLng = loc.lat !== undefined && loc.lng !== undefined;
33156
+ if (!hasLatLng) {
33157
+ return {
33158
+ file: filePath,
33159
+ type: "validation",
33160
+ message: `Missing required coordinates in business${locIndex}`,
33161
+ suggestion: "Add 'lat: 47.6062' and 'lng: -122.3321' with numeric coordinates to frontmatter (REQUIRED)"
33162
+ };
33163
+ }
33164
+ }
33165
+ return null;
33166
+ }
33167
+ async function parseMarkdownFile(filePath, cdnConfig) {
33064
33168
  try {
33065
33169
  const fileContent = await readFileAsText(filePath);
33066
33170
  if (fileContent === null) {
@@ -33090,8 +33194,28 @@ async function parseMarkdownFile(filePath) {
33090
33194
  }
33091
33195
  };
33092
33196
  }
33093
- let slug = data.slug || getBaseFilename(filePath);
33094
- const sanitizedHtml = convertMarkdownToHtml(content);
33197
+ if (data.location) {
33198
+ return {
33199
+ post: null,
33200
+ error: {
33201
+ file: filePath,
33202
+ type: "validation",
33203
+ message: "Use 'business:' instead of deprecated 'location:' field",
33204
+ suggestion: "Replace 'location:' with 'business:' in frontmatter (business requires type, name, lat, lng)"
33205
+ }
33206
+ };
33207
+ }
33208
+ if (data.business) {
33209
+ const validationError = validateBusinessLocation(data.business, filePath);
33210
+ if (validationError) {
33211
+ return {
33212
+ post: null,
33213
+ error: validationError
33214
+ };
33215
+ }
33216
+ }
33217
+ let slug = getBaseFilename(filePath);
33218
+ const sanitizedHtml = convertMarkdownToHtml(content, cdnConfig);
33095
33219
  const pacificDate = toPacificTime(data.date);
33096
33220
  const postYear = getPacificYear(data.date);
33097
33221
  const post = {
@@ -33104,17 +33228,6 @@ async function parseMarkdownFile(filePath) {
33104
33228
  url: `/${postYear}/${slug}/`,
33105
33229
  excerpt: data.excerpt || extractExcerpt(content),
33106
33230
  html: sanitizedHtml,
33107
- ...data.location && {
33108
- location: (() => {
33109
- const loc = Array.isArray(data.location) ? data.location[0] : data.location;
33110
- return {
33111
- name: loc.name,
33112
- address: loc.address,
33113
- lat: loc.lat || loc.latitude,
33114
- lng: loc.lng || loc.longitude
33115
- };
33116
- })()
33117
- },
33118
33231
  ...data.category && { category: data.category },
33119
33232
  ...data.business && {
33120
33233
  business: (() => {
@@ -33123,8 +33236,8 @@ async function parseMarkdownFile(filePath) {
33123
33236
  type: biz.type,
33124
33237
  name: biz.name,
33125
33238
  address: biz.address,
33126
- lat: biz.lat || biz.latitude,
33127
- lng: biz.lng || biz.longitude,
33239
+ lat: biz.lat,
33240
+ lng: biz.lng,
33128
33241
  ...biz.cuisine && { cuisine: biz.cuisine },
33129
33242
  ...biz.priceRange && {
33130
33243
  priceRange: biz.priceRange
@@ -33164,11 +33277,11 @@ async function parseMarkdownFile(filePath) {
33164
33277
  }
33165
33278
 
33166
33279
  // src/parser.ts
33167
- async function parseMarkdownDirectory(contentDir, strictMode = false) {
33280
+ async function parseMarkdownDirectory(contentDir, strictMode = false, cdnConfig) {
33168
33281
  try {
33169
33282
  const markdownFiles = await findFilesByPattern("**/*.md", contentDir, true);
33170
33283
  console.log(`Found ${markdownFiles.length} markdown files`);
33171
- const resultsPromises = markdownFiles.map((filePath) => parseMarkdownFile(filePath));
33284
+ const resultsPromises = markdownFiles.map((filePath) => parseMarkdownFile(filePath, cdnConfig));
33172
33285
  const results = await Promise.all(resultsPromises);
33173
33286
  const posts = [];
33174
33287
  const errors = [];
@@ -33185,7 +33298,8 @@ async function parseMarkdownDirectory(contentDir, strictMode = false) {
33185
33298
  `);
33186
33299
  const yamlErrors = errors.filter((e) => e.type === "yaml");
33187
33300
  const missingFieldErrors = errors.filter((e) => e.type === "missing_field");
33188
- const otherErrors = errors.filter((e) => e.type !== "yaml" && e.type !== "missing_field");
33301
+ const validationErrors = errors.filter((e) => e.type === "validation");
33302
+ const otherErrors = errors.filter((e) => e.type !== "yaml" && e.type !== "missing_field" && e.type !== "validation");
33189
33303
  if (yamlErrors.length > 0) {
33190
33304
  console.error(` YAML Parsing Errors (${yamlErrors.length}):`);
33191
33305
  yamlErrors.slice(0, 5).forEach((e) => {
@@ -33209,6 +33323,19 @@ async function parseMarkdownDirectory(contentDir, strictMode = false) {
33209
33323
  }
33210
33324
  console.error("");
33211
33325
  }
33326
+ if (validationErrors.length > 0) {
33327
+ console.error(` Validation Errors (${validationErrors.length}):`);
33328
+ validationErrors.slice(0, 5).forEach((e) => {
33329
+ console.error(` \u26A0\uFE0F ${e.file}: ${e.message}`);
33330
+ if (e.suggestion) {
33331
+ console.error(` \uD83D\uDCA1 ${e.suggestion}`);
33332
+ }
33333
+ });
33334
+ if (validationErrors.length > 5) {
33335
+ console.error(` ... and ${validationErrors.length - 5} more`);
33336
+ }
33337
+ console.error("");
33338
+ }
33212
33339
  if (otherErrors.length > 0) {
33213
33340
  console.error(` Other Errors (${otherErrors.length}):`);
33214
33341
  otherErrors.slice(0, 3).forEach((e) => {
@@ -33222,6 +33349,11 @@ async function parseMarkdownDirectory(contentDir, strictMode = false) {
33222
33349
  console.error(`\uD83D\uDCDD Tip: Fix YAML errors by quoting titles/descriptions with colons`);
33223
33350
  console.error(` Example: title: "My Post: A Guide" (quotes required for colons)
33224
33351
  `);
33352
+ if (validationErrors.length > 0) {
33353
+ throw new Error(`\u274C Build failed: ${validationErrors.length} validation error(s) found
33354
+ ` + ` Business locations must have: type, name, address, lat, lng
33355
+ ` + ` Run 'bunki validate' to see all errors`);
33356
+ }
33225
33357
  if (strictMode) {
33226
33358
  throw new Error(`Build failed: ${errors.length} parsing error(s) found (strictMode enabled)`);
33227
33359
  }
@@ -33497,7 +33629,7 @@ class SiteGenerator {
33497
33629
  }
33498
33630
  }
33499
33631
  const strictMode = this.options.config.strictMode ?? false;
33500
- const posts = await parseMarkdownDirectory(this.options.contentDir, strictMode);
33632
+ const posts = await parseMarkdownDirectory(this.options.contentDir, strictMode, this.options.config.cdn);
33501
33633
  const tags = {};
33502
33634
  posts.forEach((post) => {
33503
33635
  post.tagSlugs = {};
@@ -33577,14 +33709,23 @@ class SiteGenerator {
33577
33709
  const pagination = this.createPagination(yearPosts, page, pageSize, `/${year}/`);
33578
33710
  let jsonLd = "";
33579
33711
  if (page === 1) {
33580
- const schema = generateCollectionPageSchema({
33712
+ const schemas = [];
33713
+ schemas.push(generateCollectionPageSchema({
33581
33714
  title: `Posts from ${year}`,
33582
33715
  description: `Articles published in ${year}`,
33583
33716
  url: `${this.options.config.baseUrl}/${year}/`,
33584
33717
  posts: yearPosts,
33585
33718
  site: this.options.config
33586
- });
33587
- jsonLd = toScriptTag(schema);
33719
+ }));
33720
+ schemas.push(generateBreadcrumbListSchema({
33721
+ site: this.options.config,
33722
+ items: [
33723
+ { name: "Home", url: `${this.options.config.baseUrl}/` },
33724
+ { name: year, url: `${this.options.config.baseUrl}/${year}/` }
33725
+ ]
33726
+ }));
33727
+ jsonLd = schemas.map((schema) => toScriptTag(schema)).join(`
33728
+ `);
33588
33729
  }
33589
33730
  const yearPageHtml = import_nunjucks.default.render("archive.njk", {
33590
33731
  site: this.options.config,
@@ -33683,15 +33824,24 @@ class SiteGenerator {
33683
33824
  const pagination = this.createPagination(tagData.posts, page, pageSize, `/tags/${tagData.slug}/`);
33684
33825
  let jsonLd = "";
33685
33826
  if (page === 1) {
33827
+ const schemas = [];
33686
33828
  const description = tagData.description || `Articles tagged with ${tagName}`;
33687
- const schema = generateCollectionPageSchema({
33829
+ schemas.push(generateCollectionPageSchema({
33688
33830
  title: `${tagName}`,
33689
33831
  description,
33690
33832
  url: `${this.options.config.baseUrl}/tags/${tagData.slug}/`,
33691
33833
  posts: tagData.posts,
33692
33834
  site: this.options.config
33693
- });
33694
- jsonLd = toScriptTag(schema);
33835
+ }));
33836
+ schemas.push(generateBreadcrumbListSchema({
33837
+ site: this.options.config,
33838
+ items: [
33839
+ { name: "Home", url: `${this.options.config.baseUrl}/` },
33840
+ { name: tagName, url: `${this.options.config.baseUrl}/tags/${tagData.slug}/` }
33841
+ ]
33842
+ }));
33843
+ jsonLd = schemas.map((schema) => toScriptTag(schema)).join(`
33844
+ `);
33695
33845
  }
33696
33846
  const tagPageHtml = import_nunjucks.default.render("tag.njk", {
33697
33847
  site: this.options.config,
@@ -34278,7 +34428,7 @@ function createUploader(config) {
34278
34428
  }
34279
34429
 
34280
34430
  // src/utils/image-uploader.ts
34281
- var DEFAULT_IMAGES_DIR = path8.join(process.cwd(), "images");
34431
+ var DEFAULT_IMAGES_DIR = path8.join(process.cwd(), "assets");
34282
34432
  async function uploadImages(options2 = {}) {
34283
34433
  try {
34284
34434
  const imagesDir = path8.resolve(options2.images || DEFAULT_IMAGES_DIR);
package/dist/index.js CHANGED
@@ -30520,7 +30520,15 @@ core_default.registerLanguage("python", python);
30520
30520
  core_default.registerLanguage("json", json);
30521
30521
  core_default.registerLanguage("swift", swift);
30522
30522
  var noFollowExceptions = new Set;
30523
- function createMarked() {
30523
+ function transformImagePath(relativePath, config) {
30524
+ const match = relativePath.match(/^\.\.\/\.\.\/assets\/(\d{4})\/([^/]+)\/(.+)$/);
30525
+ if (!match)
30526
+ return null;
30527
+ const [, year, slug, filename] = match;
30528
+ const path2 = config.pathPattern.replace("{year}", year).replace("{slug}", slug).replace("{filename}", filename);
30529
+ return `${config.baseUrl}/${path2}`;
30530
+ }
30531
+ function createMarked(cdnConfig) {
30524
30532
  const marked = new B(markedHighlight({
30525
30533
  emptyLangClass: "hljs",
30526
30534
  langPrefix: "hljs language-",
@@ -30569,6 +30577,15 @@ function createMarked() {
30569
30577
  }
30570
30578
  }
30571
30579
  }
30580
+ if (token.type === "image" && cdnConfig?.enabled) {
30581
+ const href = token.href || "";
30582
+ if (href.startsWith("../../assets/")) {
30583
+ const transformed = transformImagePath(href, cdnConfig);
30584
+ if (transformed) {
30585
+ token.href = transformed;
30586
+ }
30587
+ }
30588
+ }
30572
30589
  },
30573
30590
  hooks: {
30574
30591
  preprocess(markdown2) {
@@ -30611,8 +30628,9 @@ function extractExcerpt(content, maxLength = 200) {
30611
30628
  const lastSpace = truncated.lastIndexOf(" ");
30612
30629
  return truncated.substring(0, lastSpace) + "...";
30613
30630
  }
30614
- function convertMarkdownToHtml(markdownContent) {
30615
- const html = marked.parse(markdownContent, { async: false });
30631
+ function convertMarkdownToHtml(markdownContent, cdnConfig) {
30632
+ const markedInstance = cdnConfig ? createMarked(cdnConfig) : marked;
30633
+ const html = markedInstance.parse(markdownContent, { async: false });
30616
30634
  let sanitized = import_sanitize_html.default(html, {
30617
30635
  allowedTags: import_sanitize_html.default.defaults.allowedTags.concat([
30618
30636
  "img",
@@ -30678,7 +30696,93 @@ function convertMarkdownToHtml(markdownContent) {
30678
30696
  sanitized = sanitized.replace(/javascript:/gi, "").replace(/vbscript:/gi, "");
30679
30697
  return sanitized;
30680
30698
  }
30681
- async function parseMarkdownFile(filePath) {
30699
+ function validateBusinessLocation(business, filePath) {
30700
+ if (!business)
30701
+ return null;
30702
+ const locations = Array.isArray(business) ? business : [business];
30703
+ for (let i = 0;i < locations.length; i++) {
30704
+ const loc = locations[i];
30705
+ const locIndex = locations.length > 1 ? ` (location ${i + 1})` : "";
30706
+ if (!loc.type) {
30707
+ return {
30708
+ file: filePath,
30709
+ type: "validation",
30710
+ message: `Missing required field 'type' in business${locIndex}`,
30711
+ suggestion: "Add 'type: Restaurant' (or Market, Park, Hotel, Museum, Cafe, Zoo, etc.) to frontmatter"
30712
+ };
30713
+ }
30714
+ const validTypes = [
30715
+ "Accommodation",
30716
+ "Apartment",
30717
+ "Attraction",
30718
+ "Beach",
30719
+ "BodyOfWater",
30720
+ "Bridge",
30721
+ "Building",
30722
+ "BusStation",
30723
+ "Cafe",
30724
+ "Campground",
30725
+ "CivicStructure",
30726
+ "EventVenue",
30727
+ "Ferry",
30728
+ "Garden",
30729
+ "HistoricalSite",
30730
+ "Hotel",
30731
+ "Hostel",
30732
+ "Landmark",
30733
+ "LodgingBusiness",
30734
+ "Market",
30735
+ "Monument",
30736
+ "Museum",
30737
+ "NaturalFeature",
30738
+ "Park",
30739
+ "Playground",
30740
+ "Restaurant",
30741
+ "ServiceCenter",
30742
+ "ShoppingCenter",
30743
+ "Store",
30744
+ "TouristAttraction",
30745
+ "TrainStation",
30746
+ "Viewpoint",
30747
+ "Zoo"
30748
+ ];
30749
+ if (!validTypes.includes(loc.type)) {
30750
+ return {
30751
+ file: filePath,
30752
+ type: "validation",
30753
+ message: `Invalid business type '${loc.type}' in business${locIndex}`,
30754
+ suggestion: `Use a valid Schema.org Place type: ${validTypes.slice(0, 10).join(", ")}, etc.`
30755
+ };
30756
+ }
30757
+ if (!loc.name) {
30758
+ return {
30759
+ file: filePath,
30760
+ type: "validation",
30761
+ message: `Missing required field 'name' in business${locIndex}`,
30762
+ suggestion: `Add 'name: "Full Business Name"' to frontmatter`
30763
+ };
30764
+ }
30765
+ if (loc.latitude !== undefined || loc.longitude !== undefined) {
30766
+ return {
30767
+ file: filePath,
30768
+ type: "validation",
30769
+ message: `Use 'lat' and 'lng' instead of 'latitude' and 'longitude' in business${locIndex}`,
30770
+ suggestion: "Replace 'latitude:' with 'lat:' and 'longitude:' with 'lng:' in frontmatter"
30771
+ };
30772
+ }
30773
+ const hasLatLng = loc.lat !== undefined && loc.lng !== undefined;
30774
+ if (!hasLatLng) {
30775
+ return {
30776
+ file: filePath,
30777
+ type: "validation",
30778
+ message: `Missing required coordinates in business${locIndex}`,
30779
+ suggestion: "Add 'lat: 47.6062' and 'lng: -122.3321' with numeric coordinates to frontmatter (REQUIRED)"
30780
+ };
30781
+ }
30782
+ }
30783
+ return null;
30784
+ }
30785
+ async function parseMarkdownFile(filePath, cdnConfig) {
30682
30786
  try {
30683
30787
  const fileContent = await readFileAsText(filePath);
30684
30788
  if (fileContent === null) {
@@ -30708,8 +30812,28 @@ async function parseMarkdownFile(filePath) {
30708
30812
  }
30709
30813
  };
30710
30814
  }
30711
- let slug = data.slug || getBaseFilename(filePath);
30712
- const sanitizedHtml = convertMarkdownToHtml(content);
30815
+ if (data.location) {
30816
+ return {
30817
+ post: null,
30818
+ error: {
30819
+ file: filePath,
30820
+ type: "validation",
30821
+ message: "Use 'business:' instead of deprecated 'location:' field",
30822
+ suggestion: "Replace 'location:' with 'business:' in frontmatter (business requires type, name, lat, lng)"
30823
+ }
30824
+ };
30825
+ }
30826
+ if (data.business) {
30827
+ const validationError = validateBusinessLocation(data.business, filePath);
30828
+ if (validationError) {
30829
+ return {
30830
+ post: null,
30831
+ error: validationError
30832
+ };
30833
+ }
30834
+ }
30835
+ let slug = getBaseFilename(filePath);
30836
+ const sanitizedHtml = convertMarkdownToHtml(content, cdnConfig);
30713
30837
  const pacificDate = toPacificTime(data.date);
30714
30838
  const postYear = getPacificYear(data.date);
30715
30839
  const post = {
@@ -30722,17 +30846,6 @@ async function parseMarkdownFile(filePath) {
30722
30846
  url: `/${postYear}/${slug}/`,
30723
30847
  excerpt: data.excerpt || extractExcerpt(content),
30724
30848
  html: sanitizedHtml,
30725
- ...data.location && {
30726
- location: (() => {
30727
- const loc = Array.isArray(data.location) ? data.location[0] : data.location;
30728
- return {
30729
- name: loc.name,
30730
- address: loc.address,
30731
- lat: loc.lat || loc.latitude,
30732
- lng: loc.lng || loc.longitude
30733
- };
30734
- })()
30735
- },
30736
30849
  ...data.category && { category: data.category },
30737
30850
  ...data.business && {
30738
30851
  business: (() => {
@@ -30741,8 +30854,8 @@ async function parseMarkdownFile(filePath) {
30741
30854
  type: biz.type,
30742
30855
  name: biz.name,
30743
30856
  address: biz.address,
30744
- lat: biz.lat || biz.latitude,
30745
- lng: biz.lng || biz.longitude,
30857
+ lat: biz.lat,
30858
+ lng: biz.lng,
30746
30859
  ...biz.cuisine && { cuisine: biz.cuisine },
30747
30860
  ...biz.priceRange && {
30748
30861
  priceRange: biz.priceRange
@@ -30782,11 +30895,11 @@ async function parseMarkdownFile(filePath) {
30782
30895
  }
30783
30896
 
30784
30897
  // src/parser.ts
30785
- async function parseMarkdownDirectory(contentDir, strictMode = false) {
30898
+ async function parseMarkdownDirectory(contentDir, strictMode = false, cdnConfig) {
30786
30899
  try {
30787
30900
  const markdownFiles = await findFilesByPattern("**/*.md", contentDir, true);
30788
30901
  console.log(`Found ${markdownFiles.length} markdown files`);
30789
- const resultsPromises = markdownFiles.map((filePath) => parseMarkdownFile(filePath));
30902
+ const resultsPromises = markdownFiles.map((filePath) => parseMarkdownFile(filePath, cdnConfig));
30790
30903
  const results = await Promise.all(resultsPromises);
30791
30904
  const posts = [];
30792
30905
  const errors = [];
@@ -30803,7 +30916,8 @@ async function parseMarkdownDirectory(contentDir, strictMode = false) {
30803
30916
  `);
30804
30917
  const yamlErrors = errors.filter((e) => e.type === "yaml");
30805
30918
  const missingFieldErrors = errors.filter((e) => e.type === "missing_field");
30806
- const otherErrors = errors.filter((e) => e.type !== "yaml" && e.type !== "missing_field");
30919
+ const validationErrors = errors.filter((e) => e.type === "validation");
30920
+ const otherErrors = errors.filter((e) => e.type !== "yaml" && e.type !== "missing_field" && e.type !== "validation");
30807
30921
  if (yamlErrors.length > 0) {
30808
30922
  console.error(` YAML Parsing Errors (${yamlErrors.length}):`);
30809
30923
  yamlErrors.slice(0, 5).forEach((e) => {
@@ -30827,6 +30941,19 @@ async function parseMarkdownDirectory(contentDir, strictMode = false) {
30827
30941
  }
30828
30942
  console.error("");
30829
30943
  }
30944
+ if (validationErrors.length > 0) {
30945
+ console.error(` Validation Errors (${validationErrors.length}):`);
30946
+ validationErrors.slice(0, 5).forEach((e) => {
30947
+ console.error(` \u26A0\uFE0F ${e.file}: ${e.message}`);
30948
+ if (e.suggestion) {
30949
+ console.error(` \uD83D\uDCA1 ${e.suggestion}`);
30950
+ }
30951
+ });
30952
+ if (validationErrors.length > 5) {
30953
+ console.error(` ... and ${validationErrors.length - 5} more`);
30954
+ }
30955
+ console.error("");
30956
+ }
30830
30957
  if (otherErrors.length > 0) {
30831
30958
  console.error(` Other Errors (${otherErrors.length}):`);
30832
30959
  otherErrors.slice(0, 3).forEach((e) => {
@@ -30840,6 +30967,11 @@ async function parseMarkdownDirectory(contentDir, strictMode = false) {
30840
30967
  console.error(`\uD83D\uDCDD Tip: Fix YAML errors by quoting titles/descriptions with colons`);
30841
30968
  console.error(` Example: title: "My Post: A Guide" (quotes required for colons)
30842
30969
  `);
30970
+ if (validationErrors.length > 0) {
30971
+ throw new Error(`\u274C Build failed: ${validationErrors.length} validation error(s) found
30972
+ ` + ` Business locations must have: type, name, address, lat, lng
30973
+ ` + ` Run 'bunki validate' to see all errors`);
30974
+ }
30843
30975
  if (strictMode) {
30844
30976
  throw new Error(`Build failed: ${errors.length} parsing error(s) found (strictMode enabled)`);
30845
30977
  }
@@ -31444,7 +31576,7 @@ class SiteGenerator {
31444
31576
  }
31445
31577
  }
31446
31578
  const strictMode = this.options.config.strictMode ?? false;
31447
- const posts = await parseMarkdownDirectory(this.options.contentDir, strictMode);
31579
+ const posts = await parseMarkdownDirectory(this.options.contentDir, strictMode, this.options.config.cdn);
31448
31580
  const tags = {};
31449
31581
  posts.forEach((post) => {
31450
31582
  post.tagSlugs = {};
@@ -31524,14 +31656,23 @@ class SiteGenerator {
31524
31656
  const pagination = this.createPagination(yearPosts, page, pageSize, `/${year}/`);
31525
31657
  let jsonLd = "";
31526
31658
  if (page === 1) {
31527
- const schema = generateCollectionPageSchema({
31659
+ const schemas = [];
31660
+ schemas.push(generateCollectionPageSchema({
31528
31661
  title: `Posts from ${year}`,
31529
31662
  description: `Articles published in ${year}`,
31530
31663
  url: `${this.options.config.baseUrl}/${year}/`,
31531
31664
  posts: yearPosts,
31532
31665
  site: this.options.config
31533
- });
31534
- jsonLd = toScriptTag(schema);
31666
+ }));
31667
+ schemas.push(generateBreadcrumbListSchema({
31668
+ site: this.options.config,
31669
+ items: [
31670
+ { name: "Home", url: `${this.options.config.baseUrl}/` },
31671
+ { name: year, url: `${this.options.config.baseUrl}/${year}/` }
31672
+ ]
31673
+ }));
31674
+ jsonLd = schemas.map((schema) => toScriptTag(schema)).join(`
31675
+ `);
31535
31676
  }
31536
31677
  const yearPageHtml = import_nunjucks.default.render("archive.njk", {
31537
31678
  site: this.options.config,
@@ -31630,15 +31771,24 @@ class SiteGenerator {
31630
31771
  const pagination = this.createPagination(tagData.posts, page, pageSize, `/tags/${tagData.slug}/`);
31631
31772
  let jsonLd = "";
31632
31773
  if (page === 1) {
31774
+ const schemas = [];
31633
31775
  const description = tagData.description || `Articles tagged with ${tagName}`;
31634
- const schema = generateCollectionPageSchema({
31776
+ schemas.push(generateCollectionPageSchema({
31635
31777
  title: `${tagName}`,
31636
31778
  description,
31637
31779
  url: `${this.options.config.baseUrl}/tags/${tagData.slug}/`,
31638
31780
  posts: tagData.posts,
31639
31781
  site: this.options.config
31640
- });
31641
- jsonLd = toScriptTag(schema);
31782
+ }));
31783
+ schemas.push(generateBreadcrumbListSchema({
31784
+ site: this.options.config,
31785
+ items: [
31786
+ { name: "Home", url: `${this.options.config.baseUrl}/` },
31787
+ { name: tagName, url: `${this.options.config.baseUrl}/tags/${tagData.slug}/` }
31788
+ ]
31789
+ }));
31790
+ jsonLd = schemas.map((schema) => toScriptTag(schema)).join(`
31791
+ `);
31642
31792
  }
31643
31793
  const tagPageHtml = import_nunjucks.default.render("tag.njk", {
31644
31794
  site: this.options.config,
@@ -32185,7 +32335,7 @@ function createUploader(config) {
32185
32335
  }
32186
32336
 
32187
32337
  // src/utils/image-uploader.ts
32188
- var DEFAULT_IMAGES_DIR = path7.join(process.cwd(), "images");
32338
+ var DEFAULT_IMAGES_DIR = path7.join(process.cwd(), "assets");
32189
32339
  async function uploadImages(options2 = {}) {
32190
32340
  try {
32191
32341
  const imagesDir = path7.resolve(options2.images || DEFAULT_IMAGES_DIR);
package/dist/parser.d.ts CHANGED
@@ -1,7 +1,7 @@
1
- import { Post } from "./types";
1
+ import { Post, CDNConfig } from "./types";
2
2
  import { type ParseError } from "./utils/markdown-utils";
3
3
  export interface ParseResult {
4
4
  posts: Post[];
5
5
  errors: ParseError[];
6
6
  }
7
- export declare function parseMarkdownDirectory(contentDir: string, strictMode?: boolean): Promise<Post[]>;
7
+ export declare function parseMarkdownDirectory(contentDir: string, strictMode?: boolean, cdnConfig?: CDNConfig): Promise<Post[]>;
package/dist/types.d.ts CHANGED
@@ -80,6 +80,17 @@ export interface CSSConfig {
80
80
  /** Whether to watch for changes in development */
81
81
  watch?: boolean;
82
82
  }
83
+ /**
84
+ * Configuration for CDN URL transformation
85
+ */
86
+ export interface CDNConfig {
87
+ /** Base URL for CDN (e.g., "https://img.beconfused.com") */
88
+ baseUrl: string;
89
+ /** Path pattern for CDN URLs (e.g., "{year}/{slug}/{filename}") */
90
+ pathPattern: string;
91
+ /** Whether CDN transformation is enabled */
92
+ enabled: boolean;
93
+ }
83
94
  /**
84
95
  * Configuration for the site
85
96
  */
@@ -98,6 +109,8 @@ export interface SiteConfig {
98
109
  s3?: S3Config;
99
110
  /** CSS processing configuration */
100
111
  css?: CSSConfig;
112
+ /** CDN URL transformation configuration */
113
+ cdn?: CDNConfig;
101
114
  /** Optional number of tags to display on homepage (sorted by count). If not set, all tags are shown */
102
115
  maxTagsOnHomepage?: number;
103
116
  /** Optional list of domains to exclude from nofollow attribute. Links to these domains will have follow attribute. */
@@ -1,10 +1,10 @@
1
- import { Post } from "../types";
1
+ import { Post, CDNConfig } from "../types";
2
2
  export declare function setNoFollowExceptions(exceptions: string[]): void;
3
3
  export declare function extractExcerpt(content: string, maxLength?: number): string;
4
- export declare function convertMarkdownToHtml(markdownContent: string): string;
4
+ export declare function convertMarkdownToHtml(markdownContent: string, cdnConfig?: CDNConfig): string;
5
5
  export interface ParseError {
6
6
  file: string;
7
- type: "yaml" | "missing_field" | "file_not_found" | "unknown";
7
+ type: "yaml" | "missing_field" | "file_not_found" | "unknown" | "validation";
8
8
  message: string;
9
9
  suggestion?: string;
10
10
  }
@@ -12,4 +12,4 @@ export interface ParseMarkdownResult {
12
12
  post: Post | null;
13
13
  error: ParseError | null;
14
14
  }
15
- export declare function parseMarkdownFile(filePath: string): Promise<ParseMarkdownResult>;
15
+ export declare function parseMarkdownFile(filePath: string, cdnConfig?: CDNConfig): Promise<ParseMarkdownResult>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunki",
3
- "version": "0.14.1",
3
+ "version": "0.16.0",
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",