bunki 0.14.0 → 0.15.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
@@ -33060,6 +33060,92 @@ function convertMarkdownToHtml(markdownContent) {
33060
33060
  sanitized = sanitized.replace(/javascript:/gi, "").replace(/vbscript:/gi, "");
33061
33061
  return sanitized;
33062
33062
  }
33063
+ function validateBusinessLocation(business, filePath) {
33064
+ if (!business)
33065
+ return null;
33066
+ const locations = Array.isArray(business) ? business : [business];
33067
+ for (let i = 0;i < locations.length; i++) {
33068
+ const loc = locations[i];
33069
+ const locIndex = locations.length > 1 ? ` (location ${i + 1})` : "";
33070
+ if (!loc.type) {
33071
+ return {
33072
+ file: filePath,
33073
+ type: "validation",
33074
+ message: `Missing required field 'type' in business${locIndex}`,
33075
+ suggestion: "Add 'type: Restaurant' (or Market, Park, Hotel, Museum, Cafe, Zoo, etc.) to frontmatter"
33076
+ };
33077
+ }
33078
+ const validTypes = [
33079
+ "Accommodation",
33080
+ "Apartment",
33081
+ "Attraction",
33082
+ "Beach",
33083
+ "BodyOfWater",
33084
+ "Bridge",
33085
+ "Building",
33086
+ "BusStation",
33087
+ "Cafe",
33088
+ "Campground",
33089
+ "CivicStructure",
33090
+ "EventVenue",
33091
+ "Ferry",
33092
+ "Garden",
33093
+ "HistoricalSite",
33094
+ "Hotel",
33095
+ "Hostel",
33096
+ "Landmark",
33097
+ "LodgingBusiness",
33098
+ "Market",
33099
+ "Monument",
33100
+ "Museum",
33101
+ "NaturalFeature",
33102
+ "Park",
33103
+ "Playground",
33104
+ "Restaurant",
33105
+ "ServiceCenter",
33106
+ "ShoppingCenter",
33107
+ "Store",
33108
+ "TouristAttraction",
33109
+ "TrainStation",
33110
+ "Viewpoint",
33111
+ "Zoo"
33112
+ ];
33113
+ if (!validTypes.includes(loc.type)) {
33114
+ return {
33115
+ file: filePath,
33116
+ type: "validation",
33117
+ message: `Invalid business type '${loc.type}' in business${locIndex}`,
33118
+ suggestion: `Use a valid Schema.org Place type: ${validTypes.slice(0, 10).join(", ")}, etc.`
33119
+ };
33120
+ }
33121
+ if (!loc.name) {
33122
+ return {
33123
+ file: filePath,
33124
+ type: "validation",
33125
+ message: `Missing required field 'name' in business${locIndex}`,
33126
+ suggestion: `Add 'name: "Full Business Name"' to frontmatter`
33127
+ };
33128
+ }
33129
+ if (loc.latitude !== undefined || loc.longitude !== undefined) {
33130
+ return {
33131
+ file: filePath,
33132
+ type: "validation",
33133
+ message: `Use 'lat' and 'lng' instead of 'latitude' and 'longitude' in business${locIndex}`,
33134
+ suggestion: "Replace 'latitude:' with 'lat:' and 'longitude:' with 'lng:' in frontmatter"
33135
+ };
33136
+ }
33137
+ const hasLatLng = loc.lat !== undefined && loc.lng !== undefined;
33138
+ if (!hasLatLng) {
33139
+ return {
33140
+ file: filePath,
33141
+ type: "validation",
33142
+ message: `Missing required coordinates in business${locIndex}`,
33143
+ suggestion: "Add 'lat: 47.6062' and 'lng: -122.3321' with numeric coordinates to frontmatter (REQUIRED)"
33144
+ };
33145
+ }
33146
+ }
33147
+ return null;
33148
+ }
33063
33149
  async function parseMarkdownFile(filePath) {
33064
33150
  try {
33065
33151
  const fileContent = await readFileAsText(filePath);
@@ -33090,6 +33176,26 @@ async function parseMarkdownFile(filePath) {
33090
33176
  }
33091
33177
  };
33092
33178
  }
33179
+ if (data.location) {
33180
+ return {
33181
+ post: null,
33182
+ error: {
33183
+ file: filePath,
33184
+ type: "validation",
33185
+ message: "Use 'business:' instead of deprecated 'location:' field",
33186
+ suggestion: "Replace 'location:' with 'business:' in frontmatter (business requires type, name, lat, lng)"
33187
+ }
33188
+ };
33189
+ }
33190
+ if (data.business) {
33191
+ const validationError = validateBusinessLocation(data.business, filePath);
33192
+ if (validationError) {
33193
+ return {
33194
+ post: null,
33195
+ error: validationError
33196
+ };
33197
+ }
33198
+ }
33093
33199
  let slug = data.slug || getBaseFilename(filePath);
33094
33200
  const sanitizedHtml = convertMarkdownToHtml(content);
33095
33201
  const pacificDate = toPacificTime(data.date);
@@ -33104,34 +33210,29 @@ async function parseMarkdownFile(filePath) {
33104
33210
  url: `/${postYear}/${slug}/`,
33105
33211
  excerpt: data.excerpt || extractExcerpt(content),
33106
33212
  html: sanitizedHtml,
33107
- ...data.location && {
33108
- location: {
33109
- name: data.location.name,
33110
- address: data.location.address,
33111
- lat: data.location.lat,
33112
- lng: data.location.lng
33113
- }
33114
- },
33115
33213
  ...data.category && { category: data.category },
33116
33214
  ...data.business && {
33117
- business: {
33118
- type: data.business.type,
33119
- name: data.business.name,
33120
- address: data.business.address,
33121
- lat: data.business.lat,
33122
- lng: data.business.lng,
33123
- ...data.business.cuisine && { cuisine: data.business.cuisine },
33124
- ...data.business.priceRange && {
33125
- priceRange: data.business.priceRange
33126
- },
33127
- ...data.business.telephone && {
33128
- telephone: data.business.telephone
33129
- },
33130
- ...data.business.url && { url: data.business.url },
33131
- ...data.business.openingHours && {
33132
- openingHours: data.business.openingHours
33133
- }
33134
- }
33215
+ business: (() => {
33216
+ const biz = Array.isArray(data.business) ? data.business[0] : data.business;
33217
+ return {
33218
+ type: biz.type,
33219
+ name: biz.name,
33220
+ address: biz.address,
33221
+ lat: biz.lat,
33222
+ lng: biz.lng,
33223
+ ...biz.cuisine && { cuisine: biz.cuisine },
33224
+ ...biz.priceRange && {
33225
+ priceRange: biz.priceRange
33226
+ },
33227
+ ...biz.telephone && {
33228
+ telephone: biz.telephone
33229
+ },
33230
+ ...biz.url && { url: biz.url },
33231
+ ...biz.openingHours && {
33232
+ openingHours: biz.openingHours
33233
+ }
33234
+ };
33235
+ })()
33135
33236
  }
33136
33237
  };
33137
33238
  return { post, error: null };
@@ -33179,7 +33280,8 @@ async function parseMarkdownDirectory(contentDir, strictMode = false) {
33179
33280
  `);
33180
33281
  const yamlErrors = errors.filter((e) => e.type === "yaml");
33181
33282
  const missingFieldErrors = errors.filter((e) => e.type === "missing_field");
33182
- const otherErrors = errors.filter((e) => e.type !== "yaml" && e.type !== "missing_field");
33283
+ const validationErrors = errors.filter((e) => e.type === "validation");
33284
+ const otherErrors = errors.filter((e) => e.type !== "yaml" && e.type !== "missing_field" && e.type !== "validation");
33183
33285
  if (yamlErrors.length > 0) {
33184
33286
  console.error(` YAML Parsing Errors (${yamlErrors.length}):`);
33185
33287
  yamlErrors.slice(0, 5).forEach((e) => {
@@ -33203,6 +33305,19 @@ async function parseMarkdownDirectory(contentDir, strictMode = false) {
33203
33305
  }
33204
33306
  console.error("");
33205
33307
  }
33308
+ if (validationErrors.length > 0) {
33309
+ console.error(` Validation Errors (${validationErrors.length}):`);
33310
+ validationErrors.slice(0, 5).forEach((e) => {
33311
+ console.error(` \u26A0\uFE0F ${e.file}: ${e.message}`);
33312
+ if (e.suggestion) {
33313
+ console.error(` \uD83D\uDCA1 ${e.suggestion}`);
33314
+ }
33315
+ });
33316
+ if (validationErrors.length > 5) {
33317
+ console.error(` ... and ${validationErrors.length - 5} more`);
33318
+ }
33319
+ console.error("");
33320
+ }
33206
33321
  if (otherErrors.length > 0) {
33207
33322
  console.error(` Other Errors (${otherErrors.length}):`);
33208
33323
  otherErrors.slice(0, 3).forEach((e) => {
@@ -33216,6 +33331,11 @@ async function parseMarkdownDirectory(contentDir, strictMode = false) {
33216
33331
  console.error(`\uD83D\uDCDD Tip: Fix YAML errors by quoting titles/descriptions with colons`);
33217
33332
  console.error(` Example: title: "My Post: A Guide" (quotes required for colons)
33218
33333
  `);
33334
+ if (validationErrors.length > 0) {
33335
+ throw new Error(`\u274C Build failed: ${validationErrors.length} validation error(s) found
33336
+ ` + ` Business locations must have: type, name, address, lat, lng
33337
+ ` + ` Run 'bunki validate' to see all errors`);
33338
+ }
33219
33339
  if (strictMode) {
33220
33340
  throw new Error(`Build failed: ${errors.length} parsing error(s) found (strictMode enabled)`);
33221
33341
  }
@@ -33571,14 +33691,23 @@ class SiteGenerator {
33571
33691
  const pagination = this.createPagination(yearPosts, page, pageSize, `/${year}/`);
33572
33692
  let jsonLd = "";
33573
33693
  if (page === 1) {
33574
- const schema = generateCollectionPageSchema({
33694
+ const schemas = [];
33695
+ schemas.push(generateCollectionPageSchema({
33575
33696
  title: `Posts from ${year}`,
33576
33697
  description: `Articles published in ${year}`,
33577
33698
  url: `${this.options.config.baseUrl}/${year}/`,
33578
33699
  posts: yearPosts,
33579
33700
  site: this.options.config
33580
- });
33581
- jsonLd = toScriptTag(schema);
33701
+ }));
33702
+ schemas.push(generateBreadcrumbListSchema({
33703
+ site: this.options.config,
33704
+ items: [
33705
+ { name: "Home", url: `${this.options.config.baseUrl}/` },
33706
+ { name: year, url: `${this.options.config.baseUrl}/${year}/` }
33707
+ ]
33708
+ }));
33709
+ jsonLd = schemas.map((schema) => toScriptTag(schema)).join(`
33710
+ `);
33582
33711
  }
33583
33712
  const yearPageHtml = import_nunjucks.default.render("archive.njk", {
33584
33713
  site: this.options.config,
@@ -33677,15 +33806,24 @@ class SiteGenerator {
33677
33806
  const pagination = this.createPagination(tagData.posts, page, pageSize, `/tags/${tagData.slug}/`);
33678
33807
  let jsonLd = "";
33679
33808
  if (page === 1) {
33809
+ const schemas = [];
33680
33810
  const description = tagData.description || `Articles tagged with ${tagName}`;
33681
- const schema = generateCollectionPageSchema({
33811
+ schemas.push(generateCollectionPageSchema({
33682
33812
  title: `${tagName}`,
33683
33813
  description,
33684
33814
  url: `${this.options.config.baseUrl}/tags/${tagData.slug}/`,
33685
33815
  posts: tagData.posts,
33686
33816
  site: this.options.config
33687
- });
33688
- jsonLd = toScriptTag(schema);
33817
+ }));
33818
+ schemas.push(generateBreadcrumbListSchema({
33819
+ site: this.options.config,
33820
+ items: [
33821
+ { name: "Home", url: `${this.options.config.baseUrl}/` },
33822
+ { name: tagName, url: `${this.options.config.baseUrl}/tags/${tagData.slug}/` }
33823
+ ]
33824
+ }));
33825
+ jsonLd = schemas.map((schema) => toScriptTag(schema)).join(`
33826
+ `);
33689
33827
  }
33690
33828
  const tagPageHtml = import_nunjucks.default.render("tag.njk", {
33691
33829
  site: this.options.config,
package/dist/index.js CHANGED
@@ -30678,6 +30678,92 @@ function convertMarkdownToHtml(markdownContent) {
30678
30678
  sanitized = sanitized.replace(/javascript:/gi, "").replace(/vbscript:/gi, "");
30679
30679
  return sanitized;
30680
30680
  }
30681
+ function validateBusinessLocation(business, filePath) {
30682
+ if (!business)
30683
+ return null;
30684
+ const locations = Array.isArray(business) ? business : [business];
30685
+ for (let i = 0;i < locations.length; i++) {
30686
+ const loc = locations[i];
30687
+ const locIndex = locations.length > 1 ? ` (location ${i + 1})` : "";
30688
+ if (!loc.type) {
30689
+ return {
30690
+ file: filePath,
30691
+ type: "validation",
30692
+ message: `Missing required field 'type' in business${locIndex}`,
30693
+ suggestion: "Add 'type: Restaurant' (or Market, Park, Hotel, Museum, Cafe, Zoo, etc.) to frontmatter"
30694
+ };
30695
+ }
30696
+ const validTypes = [
30697
+ "Accommodation",
30698
+ "Apartment",
30699
+ "Attraction",
30700
+ "Beach",
30701
+ "BodyOfWater",
30702
+ "Bridge",
30703
+ "Building",
30704
+ "BusStation",
30705
+ "Cafe",
30706
+ "Campground",
30707
+ "CivicStructure",
30708
+ "EventVenue",
30709
+ "Ferry",
30710
+ "Garden",
30711
+ "HistoricalSite",
30712
+ "Hotel",
30713
+ "Hostel",
30714
+ "Landmark",
30715
+ "LodgingBusiness",
30716
+ "Market",
30717
+ "Monument",
30718
+ "Museum",
30719
+ "NaturalFeature",
30720
+ "Park",
30721
+ "Playground",
30722
+ "Restaurant",
30723
+ "ServiceCenter",
30724
+ "ShoppingCenter",
30725
+ "Store",
30726
+ "TouristAttraction",
30727
+ "TrainStation",
30728
+ "Viewpoint",
30729
+ "Zoo"
30730
+ ];
30731
+ if (!validTypes.includes(loc.type)) {
30732
+ return {
30733
+ file: filePath,
30734
+ type: "validation",
30735
+ message: `Invalid business type '${loc.type}' in business${locIndex}`,
30736
+ suggestion: `Use a valid Schema.org Place type: ${validTypes.slice(0, 10).join(", ")}, etc.`
30737
+ };
30738
+ }
30739
+ if (!loc.name) {
30740
+ return {
30741
+ file: filePath,
30742
+ type: "validation",
30743
+ message: `Missing required field 'name' in business${locIndex}`,
30744
+ suggestion: `Add 'name: "Full Business Name"' to frontmatter`
30745
+ };
30746
+ }
30747
+ if (loc.latitude !== undefined || loc.longitude !== undefined) {
30748
+ return {
30749
+ file: filePath,
30750
+ type: "validation",
30751
+ message: `Use 'lat' and 'lng' instead of 'latitude' and 'longitude' in business${locIndex}`,
30752
+ suggestion: "Replace 'latitude:' with 'lat:' and 'longitude:' with 'lng:' in frontmatter"
30753
+ };
30754
+ }
30755
+ const hasLatLng = loc.lat !== undefined && loc.lng !== undefined;
30756
+ if (!hasLatLng) {
30757
+ return {
30758
+ file: filePath,
30759
+ type: "validation",
30760
+ message: `Missing required coordinates in business${locIndex}`,
30761
+ suggestion: "Add 'lat: 47.6062' and 'lng: -122.3321' with numeric coordinates to frontmatter (REQUIRED)"
30762
+ };
30763
+ }
30764
+ }
30765
+ return null;
30766
+ }
30681
30767
  async function parseMarkdownFile(filePath) {
30682
30768
  try {
30683
30769
  const fileContent = await readFileAsText(filePath);
@@ -30708,6 +30794,26 @@ async function parseMarkdownFile(filePath) {
30708
30794
  }
30709
30795
  };
30710
30796
  }
30797
+ if (data.location) {
30798
+ return {
30799
+ post: null,
30800
+ error: {
30801
+ file: filePath,
30802
+ type: "validation",
30803
+ message: "Use 'business:' instead of deprecated 'location:' field",
30804
+ suggestion: "Replace 'location:' with 'business:' in frontmatter (business requires type, name, lat, lng)"
30805
+ }
30806
+ };
30807
+ }
30808
+ if (data.business) {
30809
+ const validationError = validateBusinessLocation(data.business, filePath);
30810
+ if (validationError) {
30811
+ return {
30812
+ post: null,
30813
+ error: validationError
30814
+ };
30815
+ }
30816
+ }
30711
30817
  let slug = data.slug || getBaseFilename(filePath);
30712
30818
  const sanitizedHtml = convertMarkdownToHtml(content);
30713
30819
  const pacificDate = toPacificTime(data.date);
@@ -30722,34 +30828,29 @@ async function parseMarkdownFile(filePath) {
30722
30828
  url: `/${postYear}/${slug}/`,
30723
30829
  excerpt: data.excerpt || extractExcerpt(content),
30724
30830
  html: sanitizedHtml,
30725
- ...data.location && {
30726
- location: {
30727
- name: data.location.name,
30728
- address: data.location.address,
30729
- lat: data.location.lat,
30730
- lng: data.location.lng
30731
- }
30732
- },
30733
30831
  ...data.category && { category: data.category },
30734
30832
  ...data.business && {
30735
- business: {
30736
- type: data.business.type,
30737
- name: data.business.name,
30738
- address: data.business.address,
30739
- lat: data.business.lat,
30740
- lng: data.business.lng,
30741
- ...data.business.cuisine && { cuisine: data.business.cuisine },
30742
- ...data.business.priceRange && {
30743
- priceRange: data.business.priceRange
30744
- },
30745
- ...data.business.telephone && {
30746
- telephone: data.business.telephone
30747
- },
30748
- ...data.business.url && { url: data.business.url },
30749
- ...data.business.openingHours && {
30750
- openingHours: data.business.openingHours
30751
- }
30752
- }
30833
+ business: (() => {
30834
+ const biz = Array.isArray(data.business) ? data.business[0] : data.business;
30835
+ return {
30836
+ type: biz.type,
30837
+ name: biz.name,
30838
+ address: biz.address,
30839
+ lat: biz.lat,
30840
+ lng: biz.lng,
30841
+ ...biz.cuisine && { cuisine: biz.cuisine },
30842
+ ...biz.priceRange && {
30843
+ priceRange: biz.priceRange
30844
+ },
30845
+ ...biz.telephone && {
30846
+ telephone: biz.telephone
30847
+ },
30848
+ ...biz.url && { url: biz.url },
30849
+ ...biz.openingHours && {
30850
+ openingHours: biz.openingHours
30851
+ }
30852
+ };
30853
+ })()
30753
30854
  }
30754
30855
  };
30755
30856
  return { post, error: null };
@@ -30797,7 +30898,8 @@ async function parseMarkdownDirectory(contentDir, strictMode = false) {
30797
30898
  `);
30798
30899
  const yamlErrors = errors.filter((e) => e.type === "yaml");
30799
30900
  const missingFieldErrors = errors.filter((e) => e.type === "missing_field");
30800
- const otherErrors = errors.filter((e) => e.type !== "yaml" && e.type !== "missing_field");
30901
+ const validationErrors = errors.filter((e) => e.type === "validation");
30902
+ const otherErrors = errors.filter((e) => e.type !== "yaml" && e.type !== "missing_field" && e.type !== "validation");
30801
30903
  if (yamlErrors.length > 0) {
30802
30904
  console.error(` YAML Parsing Errors (${yamlErrors.length}):`);
30803
30905
  yamlErrors.slice(0, 5).forEach((e) => {
@@ -30821,6 +30923,19 @@ async function parseMarkdownDirectory(contentDir, strictMode = false) {
30821
30923
  }
30822
30924
  console.error("");
30823
30925
  }
30926
+ if (validationErrors.length > 0) {
30927
+ console.error(` Validation Errors (${validationErrors.length}):`);
30928
+ validationErrors.slice(0, 5).forEach((e) => {
30929
+ console.error(` \u26A0\uFE0F ${e.file}: ${e.message}`);
30930
+ if (e.suggestion) {
30931
+ console.error(` \uD83D\uDCA1 ${e.suggestion}`);
30932
+ }
30933
+ });
30934
+ if (validationErrors.length > 5) {
30935
+ console.error(` ... and ${validationErrors.length - 5} more`);
30936
+ }
30937
+ console.error("");
30938
+ }
30824
30939
  if (otherErrors.length > 0) {
30825
30940
  console.error(` Other Errors (${otherErrors.length}):`);
30826
30941
  otherErrors.slice(0, 3).forEach((e) => {
@@ -30834,6 +30949,11 @@ async function parseMarkdownDirectory(contentDir, strictMode = false) {
30834
30949
  console.error(`\uD83D\uDCDD Tip: Fix YAML errors by quoting titles/descriptions with colons`);
30835
30950
  console.error(` Example: title: "My Post: A Guide" (quotes required for colons)
30836
30951
  `);
30952
+ if (validationErrors.length > 0) {
30953
+ throw new Error(`\u274C Build failed: ${validationErrors.length} validation error(s) found
30954
+ ` + ` Business locations must have: type, name, address, lat, lng
30955
+ ` + ` Run 'bunki validate' to see all errors`);
30956
+ }
30837
30957
  if (strictMode) {
30838
30958
  throw new Error(`Build failed: ${errors.length} parsing error(s) found (strictMode enabled)`);
30839
30959
  }
@@ -31518,14 +31638,23 @@ class SiteGenerator {
31518
31638
  const pagination = this.createPagination(yearPosts, page, pageSize, `/${year}/`);
31519
31639
  let jsonLd = "";
31520
31640
  if (page === 1) {
31521
- const schema = generateCollectionPageSchema({
31641
+ const schemas = [];
31642
+ schemas.push(generateCollectionPageSchema({
31522
31643
  title: `Posts from ${year}`,
31523
31644
  description: `Articles published in ${year}`,
31524
31645
  url: `${this.options.config.baseUrl}/${year}/`,
31525
31646
  posts: yearPosts,
31526
31647
  site: this.options.config
31527
- });
31528
- jsonLd = toScriptTag(schema);
31648
+ }));
31649
+ schemas.push(generateBreadcrumbListSchema({
31650
+ site: this.options.config,
31651
+ items: [
31652
+ { name: "Home", url: `${this.options.config.baseUrl}/` },
31653
+ { name: year, url: `${this.options.config.baseUrl}/${year}/` }
31654
+ ]
31655
+ }));
31656
+ jsonLd = schemas.map((schema) => toScriptTag(schema)).join(`
31657
+ `);
31529
31658
  }
31530
31659
  const yearPageHtml = import_nunjucks.default.render("archive.njk", {
31531
31660
  site: this.options.config,
@@ -31624,15 +31753,24 @@ class SiteGenerator {
31624
31753
  const pagination = this.createPagination(tagData.posts, page, pageSize, `/tags/${tagData.slug}/`);
31625
31754
  let jsonLd = "";
31626
31755
  if (page === 1) {
31756
+ const schemas = [];
31627
31757
  const description = tagData.description || `Articles tagged with ${tagName}`;
31628
- const schema = generateCollectionPageSchema({
31758
+ schemas.push(generateCollectionPageSchema({
31629
31759
  title: `${tagName}`,
31630
31760
  description,
31631
31761
  url: `${this.options.config.baseUrl}/tags/${tagData.slug}/`,
31632
31762
  posts: tagData.posts,
31633
31763
  site: this.options.config
31634
- });
31635
- jsonLd = toScriptTag(schema);
31764
+ }));
31765
+ schemas.push(generateBreadcrumbListSchema({
31766
+ site: this.options.config,
31767
+ items: [
31768
+ { name: "Home", url: `${this.options.config.baseUrl}/` },
31769
+ { name: tagName, url: `${this.options.config.baseUrl}/tags/${tagData.slug}/` }
31770
+ ]
31771
+ }));
31772
+ jsonLd = schemas.map((schema) => toScriptTag(schema)).join(`
31773
+ `);
31636
31774
  }
31637
31775
  const tagPageHtml = import_nunjucks.default.render("tag.njk", {
31638
31776
  site: this.options.config,
@@ -4,7 +4,7 @@ export declare function extractExcerpt(content: string, maxLength?: number): str
4
4
  export declare function convertMarkdownToHtml(markdownContent: string): 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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunki",
3
- "version": "0.14.0",
3
+ "version": "0.15.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",