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 +42 -1
- package/dist/cli.js +180 -30
- package/dist/index.js +180 -30
- package/dist/parser.d.ts +2 -2
- package/dist/types.d.ts +13 -0
- package/dist/utils/markdown-utils.d.ts +4 -4
- package/package.json +1 -1
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
33094
|
-
|
|
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
|
|
33127
|
-
lng: biz.lng
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(), "
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
30712
|
-
|
|
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
|
|
30745
|
-
lng: biz.lng
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(), "
|
|
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