bunki 0.14.1 → 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,17 +33210,6 @@ 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
- 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
33213
  ...data.category && { category: data.category },
33119
33214
  ...data.business && {
33120
33215
  business: (() => {
@@ -33123,8 +33218,8 @@ async function parseMarkdownFile(filePath) {
33123
33218
  type: biz.type,
33124
33219
  name: biz.name,
33125
33220
  address: biz.address,
33126
- lat: biz.lat || biz.latitude,
33127
- lng: biz.lng || biz.longitude,
33221
+ lat: biz.lat,
33222
+ lng: biz.lng,
33128
33223
  ...biz.cuisine && { cuisine: biz.cuisine },
33129
33224
  ...biz.priceRange && {
33130
33225
  priceRange: biz.priceRange
@@ -33185,7 +33280,8 @@ async function parseMarkdownDirectory(contentDir, strictMode = false) {
33185
33280
  `);
33186
33281
  const yamlErrors = errors.filter((e) => e.type === "yaml");
33187
33282
  const missingFieldErrors = errors.filter((e) => e.type === "missing_field");
33188
- 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");
33189
33285
  if (yamlErrors.length > 0) {
33190
33286
  console.error(` YAML Parsing Errors (${yamlErrors.length}):`);
33191
33287
  yamlErrors.slice(0, 5).forEach((e) => {
@@ -33209,6 +33305,19 @@ async function parseMarkdownDirectory(contentDir, strictMode = false) {
33209
33305
  }
33210
33306
  console.error("");
33211
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
+ }
33212
33321
  if (otherErrors.length > 0) {
33213
33322
  console.error(` Other Errors (${otherErrors.length}):`);
33214
33323
  otherErrors.slice(0, 3).forEach((e) => {
@@ -33222,6 +33331,11 @@ async function parseMarkdownDirectory(contentDir, strictMode = false) {
33222
33331
  console.error(`\uD83D\uDCDD Tip: Fix YAML errors by quoting titles/descriptions with colons`);
33223
33332
  console.error(` Example: title: "My Post: A Guide" (quotes required for colons)
33224
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
+ }
33225
33339
  if (strictMode) {
33226
33340
  throw new Error(`Build failed: ${errors.length} parsing error(s) found (strictMode enabled)`);
33227
33341
  }
@@ -33577,14 +33691,23 @@ class SiteGenerator {
33577
33691
  const pagination = this.createPagination(yearPosts, page, pageSize, `/${year}/`);
33578
33692
  let jsonLd = "";
33579
33693
  if (page === 1) {
33580
- const schema = generateCollectionPageSchema({
33694
+ const schemas = [];
33695
+ schemas.push(generateCollectionPageSchema({
33581
33696
  title: `Posts from ${year}`,
33582
33697
  description: `Articles published in ${year}`,
33583
33698
  url: `${this.options.config.baseUrl}/${year}/`,
33584
33699
  posts: yearPosts,
33585
33700
  site: this.options.config
33586
- });
33587
- 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
+ `);
33588
33711
  }
33589
33712
  const yearPageHtml = import_nunjucks.default.render("archive.njk", {
33590
33713
  site: this.options.config,
@@ -33683,15 +33806,24 @@ class SiteGenerator {
33683
33806
  const pagination = this.createPagination(tagData.posts, page, pageSize, `/tags/${tagData.slug}/`);
33684
33807
  let jsonLd = "";
33685
33808
  if (page === 1) {
33809
+ const schemas = [];
33686
33810
  const description = tagData.description || `Articles tagged with ${tagName}`;
33687
- const schema = generateCollectionPageSchema({
33811
+ schemas.push(generateCollectionPageSchema({
33688
33812
  title: `${tagName}`,
33689
33813
  description,
33690
33814
  url: `${this.options.config.baseUrl}/tags/${tagData.slug}/`,
33691
33815
  posts: tagData.posts,
33692
33816
  site: this.options.config
33693
- });
33694
- 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
+ `);
33695
33827
  }
33696
33828
  const tagPageHtml = import_nunjucks.default.render("tag.njk", {
33697
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,17 +30828,6 @@ 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
- 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
30831
  ...data.category && { category: data.category },
30737
30832
  ...data.business && {
30738
30833
  business: (() => {
@@ -30741,8 +30836,8 @@ async function parseMarkdownFile(filePath) {
30741
30836
  type: biz.type,
30742
30837
  name: biz.name,
30743
30838
  address: biz.address,
30744
- lat: biz.lat || biz.latitude,
30745
- lng: biz.lng || biz.longitude,
30839
+ lat: biz.lat,
30840
+ lng: biz.lng,
30746
30841
  ...biz.cuisine && { cuisine: biz.cuisine },
30747
30842
  ...biz.priceRange && {
30748
30843
  priceRange: biz.priceRange
@@ -30803,7 +30898,8 @@ async function parseMarkdownDirectory(contentDir, strictMode = false) {
30803
30898
  `);
30804
30899
  const yamlErrors = errors.filter((e) => e.type === "yaml");
30805
30900
  const missingFieldErrors = errors.filter((e) => e.type === "missing_field");
30806
- 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");
30807
30903
  if (yamlErrors.length > 0) {
30808
30904
  console.error(` YAML Parsing Errors (${yamlErrors.length}):`);
30809
30905
  yamlErrors.slice(0, 5).forEach((e) => {
@@ -30827,6 +30923,19 @@ async function parseMarkdownDirectory(contentDir, strictMode = false) {
30827
30923
  }
30828
30924
  console.error("");
30829
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
+ }
30830
30939
  if (otherErrors.length > 0) {
30831
30940
  console.error(` Other Errors (${otherErrors.length}):`);
30832
30941
  otherErrors.slice(0, 3).forEach((e) => {
@@ -30840,6 +30949,11 @@ async function parseMarkdownDirectory(contentDir, strictMode = false) {
30840
30949
  console.error(`\uD83D\uDCDD Tip: Fix YAML errors by quoting titles/descriptions with colons`);
30841
30950
  console.error(` Example: title: "My Post: A Guide" (quotes required for colons)
30842
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
+ }
30843
30957
  if (strictMode) {
30844
30958
  throw new Error(`Build failed: ${errors.length} parsing error(s) found (strictMode enabled)`);
30845
30959
  }
@@ -31524,14 +31638,23 @@ class SiteGenerator {
31524
31638
  const pagination = this.createPagination(yearPosts, page, pageSize, `/${year}/`);
31525
31639
  let jsonLd = "";
31526
31640
  if (page === 1) {
31527
- const schema = generateCollectionPageSchema({
31641
+ const schemas = [];
31642
+ schemas.push(generateCollectionPageSchema({
31528
31643
  title: `Posts from ${year}`,
31529
31644
  description: `Articles published in ${year}`,
31530
31645
  url: `${this.options.config.baseUrl}/${year}/`,
31531
31646
  posts: yearPosts,
31532
31647
  site: this.options.config
31533
- });
31534
- 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
+ `);
31535
31658
  }
31536
31659
  const yearPageHtml = import_nunjucks.default.render("archive.njk", {
31537
31660
  site: this.options.config,
@@ -31630,15 +31753,24 @@ class SiteGenerator {
31630
31753
  const pagination = this.createPagination(tagData.posts, page, pageSize, `/tags/${tagData.slug}/`);
31631
31754
  let jsonLd = "";
31632
31755
  if (page === 1) {
31756
+ const schemas = [];
31633
31757
  const description = tagData.description || `Articles tagged with ${tagName}`;
31634
- const schema = generateCollectionPageSchema({
31758
+ schemas.push(generateCollectionPageSchema({
31635
31759
  title: `${tagName}`,
31636
31760
  description,
31637
31761
  url: `${this.options.config.baseUrl}/tags/${tagData.slug}/`,
31638
31762
  posts: tagData.posts,
31639
31763
  site: this.options.config
31640
- });
31641
- 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
+ `);
31642
31774
  }
31643
31775
  const tagPageHtml = import_nunjucks.default.render("tag.njk", {
31644
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.1",
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",