bunki 0.7.0 → 0.8.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 +199 -3
- package/dist/cli.js +220 -55
- package/dist/index.js +219 -54
- package/dist/utils/json-ld.d.ts +205 -0
- package/dist/utils/s3-uploader.d.ts +6 -0
- package/package.json +8 -8
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[](https://coveralls.io/github/kahwee/bunki?branch=main)
|
|
5
5
|
[](https://badge.fury.io/js/bunki)
|
|
6
6
|
|
|
7
|
-
Fast static site generator for blogs and documentation built with Bun. Supports Markdown + frontmatter, tags, year-based archives, pagination, RSS feeds, sitemaps, secure HTML sanitization, syntax highlighting, PostCSS pipelines, media uploads (images & videos to S3/R2), incremental uploads with year filtering, and Nunjucks templating.
|
|
7
|
+
Fast static site generator for blogs and documentation built with Bun. Supports Markdown + frontmatter, tags, year-based archives, pagination, RSS feeds, sitemaps, JSON-LD structured data for SEO, secure HTML sanitization, syntax highlighting, PostCSS pipelines, media uploads (images & videos to S3/R2), incremental uploads with year filtering, and Nunjucks templating.
|
|
8
8
|
|
|
9
9
|
## Install
|
|
10
10
|
|
|
@@ -122,6 +122,188 @@ Create `templates/styles/main.css`:
|
|
|
122
122
|
|
|
123
123
|
CSS is processed automatically during `bunki generate`.
|
|
124
124
|
|
|
125
|
+
## JSON-LD Structured Data for SEO
|
|
126
|
+
|
|
127
|
+
Bunki automatically generates [JSON-LD](https://json-ld.org/) structured data markup for enhanced SEO and search engine visibility. JSON-LD (JavaScript Object Notation for Linked Data) is Google's recommended format for structured data.
|
|
128
|
+
|
|
129
|
+
### What is JSON-LD?
|
|
130
|
+
|
|
131
|
+
JSON-LD helps search engines better understand your content by providing explicit, structured information about your pages. This can lead to:
|
|
132
|
+
|
|
133
|
+
- **Rich snippets** in search results (article previews, star ratings, etc.)
|
|
134
|
+
- **Better content indexing** and understanding by search engines
|
|
135
|
+
- **Improved click-through rates** from search results
|
|
136
|
+
- **Knowledge graph integration** with Google, Bing, and other search engines
|
|
137
|
+
|
|
138
|
+
### Automatic Schema Generation
|
|
139
|
+
|
|
140
|
+
Bunki automatically generates appropriate schemas for different page types:
|
|
141
|
+
|
|
142
|
+
#### Blog Posts (BlogPosting Schema)
|
|
143
|
+
|
|
144
|
+
Every blog post includes comprehensive `BlogPosting` schema with:
|
|
145
|
+
|
|
146
|
+
- Headline and description
|
|
147
|
+
- Publication and modification dates
|
|
148
|
+
- Author information
|
|
149
|
+
- Publisher details
|
|
150
|
+
- Article keywords (from tags)
|
|
151
|
+
- Word count
|
|
152
|
+
- Featured image (automatically extracted)
|
|
153
|
+
- Language information
|
|
154
|
+
|
|
155
|
+
Example output in your HTML:
|
|
156
|
+
|
|
157
|
+
```html
|
|
158
|
+
<script type="application/ld+json">
|
|
159
|
+
{
|
|
160
|
+
"@context": "https://schema.org",
|
|
161
|
+
"@type": "BlogPosting",
|
|
162
|
+
"headline": "Getting Started with Bun",
|
|
163
|
+
"description": "Learn how to get started with Bun, the fast JavaScript runtime.",
|
|
164
|
+
"url": "https://example.com/2025/getting-started-with-bun/",
|
|
165
|
+
"datePublished": "2025-01-15T10:30:00.000Z",
|
|
166
|
+
"dateModified": "2025-01-15T10:30:00.000Z",
|
|
167
|
+
"author": {
|
|
168
|
+
"@type": "Person",
|
|
169
|
+
"name": "John Doe",
|
|
170
|
+
"email": "john@example.com"
|
|
171
|
+
},
|
|
172
|
+
"publisher": {
|
|
173
|
+
"@type": "Organization",
|
|
174
|
+
"name": "My Blog",
|
|
175
|
+
"url": "https://example.com"
|
|
176
|
+
},
|
|
177
|
+
"keywords": "bun, javascript, performance",
|
|
178
|
+
"image": "https://example.com/images/bun-logo.png"
|
|
179
|
+
}
|
|
180
|
+
</script>
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
#### Homepage (WebSite & Organization Schemas)
|
|
184
|
+
|
|
185
|
+
The homepage includes dual schemas:
|
|
186
|
+
|
|
187
|
+
1. **WebSite Schema**: Defines the website entity
|
|
188
|
+
2. **Organization Schema**: Defines the publisher/organization
|
|
189
|
+
|
|
190
|
+
```html
|
|
191
|
+
<script type="application/ld+json">
|
|
192
|
+
{
|
|
193
|
+
"@context": "https://schema.org",
|
|
194
|
+
"@type": "WebSite",
|
|
195
|
+
"name": "My Blog",
|
|
196
|
+
"url": "https://example.com",
|
|
197
|
+
"description": "My thoughts and ideas",
|
|
198
|
+
"potentialAction": {
|
|
199
|
+
"@type": "SearchAction",
|
|
200
|
+
"target": {
|
|
201
|
+
"@type": "EntryPoint",
|
|
202
|
+
"urlTemplate": "https://example.com/search?q={search_term_string}"
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
</script>
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
#### Breadcrumbs (BreadcrumbList Schema)
|
|
210
|
+
|
|
211
|
+
All pages include breadcrumb navigation for better site hierarchy understanding:
|
|
212
|
+
|
|
213
|
+
```html
|
|
214
|
+
<script type="application/ld+json">
|
|
215
|
+
{
|
|
216
|
+
"@context": "https://schema.org",
|
|
217
|
+
"@type": "BreadcrumbList",
|
|
218
|
+
"itemListElement": [
|
|
219
|
+
{
|
|
220
|
+
"@type": "ListItem",
|
|
221
|
+
"position": 1,
|
|
222
|
+
"name": "Home",
|
|
223
|
+
"item": "https://example.com"
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
"@type": "ListItem",
|
|
227
|
+
"position": 2,
|
|
228
|
+
"name": "Getting Started with Bun",
|
|
229
|
+
"item": "https://example.com/2025/getting-started-with-bun/"
|
|
230
|
+
}
|
|
231
|
+
]
|
|
232
|
+
}
|
|
233
|
+
</script>
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
### Configuration for SEO
|
|
237
|
+
|
|
238
|
+
Enhance your JSON-LD output by providing complete author and site information in `bunki.config.ts`:
|
|
239
|
+
|
|
240
|
+
```typescript
|
|
241
|
+
import { SiteConfig } from "bunki";
|
|
242
|
+
|
|
243
|
+
export default (): SiteConfig => ({
|
|
244
|
+
title: "My Blog",
|
|
245
|
+
description: "My thoughts and ideas on web development",
|
|
246
|
+
baseUrl: "https://example.com",
|
|
247
|
+
domain: "example.com",
|
|
248
|
+
|
|
249
|
+
// Author information (used in BlogPosting schema)
|
|
250
|
+
authorName: "John Doe",
|
|
251
|
+
authorEmail: "john@example.com",
|
|
252
|
+
|
|
253
|
+
// RSS/SEO configuration
|
|
254
|
+
rssLanguage: "en-US", // Language code for content
|
|
255
|
+
copyright: "Copyright © 2025 My Blog",
|
|
256
|
+
|
|
257
|
+
// ... other config
|
|
258
|
+
});
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
### Testing Your JSON-LD
|
|
262
|
+
|
|
263
|
+
You can validate your structured data using these tools:
|
|
264
|
+
|
|
265
|
+
1. **[Google Rich Results Test](https://search.google.com/test/rich-results)** - Test how Google sees your structured data
|
|
266
|
+
2. **[Schema.org Validator](https://validator.schema.org/)** - Validate JSON-LD syntax
|
|
267
|
+
3. **[Structured Data Linter](http://linter.structured-data.org/)** - Check for errors and warnings
|
|
268
|
+
|
|
269
|
+
### Supported Schema Types
|
|
270
|
+
|
|
271
|
+
Bunki currently supports these Schema.org types:
|
|
272
|
+
|
|
273
|
+
- **BlogPosting** - Individual blog posts and articles
|
|
274
|
+
- **WebSite** - Homepage and site-wide metadata
|
|
275
|
+
- **Organization** - Publisher/organization information
|
|
276
|
+
- **Person** - Author information
|
|
277
|
+
- **BreadcrumbList** - Navigation breadcrumbs
|
|
278
|
+
|
|
279
|
+
### How It Works
|
|
280
|
+
|
|
281
|
+
JSON-LD generation is completely automatic:
|
|
282
|
+
|
|
283
|
+
1. **Post Creation**: When you write a post with frontmatter, Bunki extracts metadata
|
|
284
|
+
2. **Site Generation**: During `bunki generate`, appropriate schemas are created
|
|
285
|
+
3. **Template Injection**: JSON-LD scripts are automatically injected into `<head>`
|
|
286
|
+
4. **Image Extraction**: The first image in your post content is automatically used as the featured image
|
|
287
|
+
|
|
288
|
+
No manual configuration needed - just run `bunki generate` and your site will have complete structured data!
|
|
289
|
+
|
|
290
|
+
### Best Practices
|
|
291
|
+
|
|
292
|
+
To maximize SEO benefits:
|
|
293
|
+
|
|
294
|
+
1. **Use descriptive titles** - Your post title becomes the schema headline
|
|
295
|
+
2. **Write good excerpts** - These become schema descriptions
|
|
296
|
+
3. **Include images** - First image in content is used as featured image
|
|
297
|
+
4. **Tag your posts** - Tags become schema keywords
|
|
298
|
+
5. **Set author info** - Complete `authorName` and `authorEmail` in config
|
|
299
|
+
6. **Use ISO 8601 dates** - Format: `2025-01-15T10:30:00-07:00`
|
|
300
|
+
|
|
301
|
+
### Further Reading
|
|
302
|
+
|
|
303
|
+
- [Schema.org Documentation](https://schema.org/)
|
|
304
|
+
- [Google Search Central - Structured Data](https://developers.google.com/search/docs/appearance/structured-data/intro-structured-data)
|
|
305
|
+
- [JSON-LD Official Spec](https://json-ld.org/)
|
|
306
|
+
|
|
125
307
|
## Image Management
|
|
126
308
|
|
|
127
309
|
### Overview
|
|
@@ -568,7 +750,8 @@ dist/
|
|
|
568
750
|
- **Styling**: Built-in PostCSS support for modern CSS frameworks
|
|
569
751
|
- **Media Management**: Direct S3/R2 uploads for images and MP4 videos with URL mapping
|
|
570
752
|
- **Incremental Uploads**: Year-based filtering (`--min-year`) for large media collections
|
|
571
|
-
- **SEO**: Automatic RSS feeds, sitemaps, meta tags
|
|
753
|
+
- **SEO**: Automatic RSS feeds, sitemaps, meta tags, and JSON-LD structured data
|
|
754
|
+
- **JSON-LD Structured Data**: Automatic Schema.org markup (BlogPosting, WebSite, Organization, BreadcrumbList)
|
|
572
755
|
- **Pagination**: Configurable posts per page
|
|
573
756
|
- **Archives**: Year-based and tag-based organization
|
|
574
757
|
|
|
@@ -606,7 +789,20 @@ bunki/
|
|
|
606
789
|
|
|
607
790
|
## Changelog
|
|
608
791
|
|
|
609
|
-
### v0.
|
|
792
|
+
### v0.8.0 (Current)
|
|
793
|
+
|
|
794
|
+
- **JSON-LD Structured Data**: Automatic Schema.org markup generation for enhanced SEO
|
|
795
|
+
- BlogPosting schema for individual blog posts with author, keywords, images
|
|
796
|
+
- WebSite schema for homepage with search action
|
|
797
|
+
- Organization schema for publisher information
|
|
798
|
+
- BreadcrumbList schema for navigation hierarchy
|
|
799
|
+
- Automatic featured image extraction from post content
|
|
800
|
+
- **Comprehensive SEO**: Complete structured data support following Google best practices
|
|
801
|
+
- **Zero configuration**: JSON-LD automatically generated during site build
|
|
802
|
+
- **Well documented**: Extensive README section with examples and validation tools
|
|
803
|
+
- **Fully tested**: 60+ new tests covering all JSON-LD schema types
|
|
804
|
+
|
|
805
|
+
### v0.7.0
|
|
610
806
|
|
|
611
807
|
- **Media uploads**: Added MP4 video support alongside image uploads
|
|
612
808
|
- **Incremental uploads**: Year-based filtering with `--min-year` option
|
package/dist/cli.js
CHANGED
|
@@ -1596,7 +1596,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
|
|
|
1596
1596
|
return arg.length > 1 && arg[0] === "-";
|
|
1597
1597
|
}
|
|
1598
1598
|
const negativeNumberArg = (arg) => {
|
|
1599
|
-
if (
|
|
1599
|
+
if (!/^-(\d+|\d*\.\d+)(e[+-]?\d+)?$/.test(arg))
|
|
1600
1600
|
return false;
|
|
1601
1601
|
return !this._getCommandAndAncestors().some((cmd) => cmd.options.map((opt) => opt.short).some((short) => /^-\d$/.test(short)));
|
|
1602
1602
|
};
|
|
@@ -32909,6 +32909,152 @@ async function parseMarkdownDirectory(contentDir) {
|
|
|
32909
32909
|
}
|
|
32910
32910
|
}
|
|
32911
32911
|
|
|
32912
|
+
// src/utils/json-ld.ts
|
|
32913
|
+
function generateOrganizationSchema(site) {
|
|
32914
|
+
const organization = {
|
|
32915
|
+
"@context": "https://schema.org",
|
|
32916
|
+
"@type": "Organization",
|
|
32917
|
+
name: site.title,
|
|
32918
|
+
url: site.baseUrl
|
|
32919
|
+
};
|
|
32920
|
+
if (site.description) {
|
|
32921
|
+
organization.description = site.description;
|
|
32922
|
+
}
|
|
32923
|
+
return organization;
|
|
32924
|
+
}
|
|
32925
|
+
function generateBlogPostingSchema(options2) {
|
|
32926
|
+
const { post, site, imageUrl, dateModified } = options2;
|
|
32927
|
+
const postUrl = `${site.baseUrl}${post.url}`;
|
|
32928
|
+
const blogPosting = {
|
|
32929
|
+
"@context": "https://schema.org",
|
|
32930
|
+
"@type": "BlogPosting",
|
|
32931
|
+
headline: post.title,
|
|
32932
|
+
description: post.excerpt,
|
|
32933
|
+
url: postUrl,
|
|
32934
|
+
mainEntityOfPage: {
|
|
32935
|
+
"@type": "WebPage",
|
|
32936
|
+
"@id": postUrl
|
|
32937
|
+
},
|
|
32938
|
+
datePublished: post.date,
|
|
32939
|
+
dateModified: dateModified || post.date
|
|
32940
|
+
};
|
|
32941
|
+
if (site.authorName) {
|
|
32942
|
+
blogPosting.author = {
|
|
32943
|
+
"@type": "Person",
|
|
32944
|
+
name: site.authorName,
|
|
32945
|
+
...site.authorEmail && { email: site.authorEmail }
|
|
32946
|
+
};
|
|
32947
|
+
}
|
|
32948
|
+
blogPosting.publisher = {
|
|
32949
|
+
"@type": "Organization",
|
|
32950
|
+
name: site.title,
|
|
32951
|
+
url: site.baseUrl
|
|
32952
|
+
};
|
|
32953
|
+
if (imageUrl) {
|
|
32954
|
+
blogPosting.image = imageUrl;
|
|
32955
|
+
}
|
|
32956
|
+
if (post.tags && post.tags.length > 0) {
|
|
32957
|
+
blogPosting.keywords = post.tags.join(", ");
|
|
32958
|
+
}
|
|
32959
|
+
if (post.tags && post.tags.length > 0) {
|
|
32960
|
+
blogPosting.articleSection = post.tags[0];
|
|
32961
|
+
}
|
|
32962
|
+
if (post.content) {
|
|
32963
|
+
const wordCount = post.content.split(/\s+/).length;
|
|
32964
|
+
blogPosting.wordCount = wordCount;
|
|
32965
|
+
}
|
|
32966
|
+
blogPosting.inLanguage = site.rssLanguage || "en-US";
|
|
32967
|
+
return blogPosting;
|
|
32968
|
+
}
|
|
32969
|
+
function generateWebSiteSchema(options2) {
|
|
32970
|
+
const { site } = options2;
|
|
32971
|
+
const webSite = {
|
|
32972
|
+
"@context": "https://schema.org",
|
|
32973
|
+
"@type": "WebSite",
|
|
32974
|
+
name: site.title,
|
|
32975
|
+
url: site.baseUrl
|
|
32976
|
+
};
|
|
32977
|
+
if (site.description) {
|
|
32978
|
+
webSite.description = site.description;
|
|
32979
|
+
}
|
|
32980
|
+
webSite.potentialAction = {
|
|
32981
|
+
"@type": "SearchAction",
|
|
32982
|
+
target: {
|
|
32983
|
+
"@type": "EntryPoint",
|
|
32984
|
+
urlTemplate: `${site.baseUrl}/search?q={search_term_string}`
|
|
32985
|
+
},
|
|
32986
|
+
"query-input": "required name=search_term_string"
|
|
32987
|
+
};
|
|
32988
|
+
return webSite;
|
|
32989
|
+
}
|
|
32990
|
+
function generateBreadcrumbListSchema(options2) {
|
|
32991
|
+
const { site, post, items } = options2;
|
|
32992
|
+
const breadcrumbs = {
|
|
32993
|
+
"@context": "https://schema.org",
|
|
32994
|
+
"@type": "BreadcrumbList",
|
|
32995
|
+
itemListElement: []
|
|
32996
|
+
};
|
|
32997
|
+
if (items && items.length > 0) {
|
|
32998
|
+
breadcrumbs.itemListElement = items.map((item, index) => ({
|
|
32999
|
+
"@type": "ListItem",
|
|
33000
|
+
position: index + 1,
|
|
33001
|
+
name: item.name,
|
|
33002
|
+
item: item.url
|
|
33003
|
+
}));
|
|
33004
|
+
return breadcrumbs;
|
|
33005
|
+
}
|
|
33006
|
+
const homeItem = {
|
|
33007
|
+
"@type": "ListItem",
|
|
33008
|
+
position: 1,
|
|
33009
|
+
name: "Home",
|
|
33010
|
+
item: site.baseUrl
|
|
33011
|
+
};
|
|
33012
|
+
breadcrumbs.itemListElement.push(homeItem);
|
|
33013
|
+
if (post) {
|
|
33014
|
+
breadcrumbs.itemListElement.push({
|
|
33015
|
+
"@type": "ListItem",
|
|
33016
|
+
position: 2,
|
|
33017
|
+
name: post.title,
|
|
33018
|
+
item: `${site.baseUrl}${post.url}`
|
|
33019
|
+
});
|
|
33020
|
+
}
|
|
33021
|
+
return breadcrumbs;
|
|
33022
|
+
}
|
|
33023
|
+
function toScriptTag(jsonLd) {
|
|
33024
|
+
const json2 = JSON.stringify(jsonLd, null, 2);
|
|
33025
|
+
return `<script type="application/ld+json">
|
|
33026
|
+
${json2}
|
|
33027
|
+
</script>`;
|
|
33028
|
+
}
|
|
33029
|
+
function extractFirstImageUrl(html, baseUrl) {
|
|
33030
|
+
const imgMatch = html.match(/<img[^>]+src=["']([^"']+)["']/i);
|
|
33031
|
+
if (!imgMatch || !imgMatch[1]) {
|
|
33032
|
+
return;
|
|
33033
|
+
}
|
|
33034
|
+
const src = imgMatch[1];
|
|
33035
|
+
if (src.startsWith("http://") || src.startsWith("https://")) {
|
|
33036
|
+
return src;
|
|
33037
|
+
}
|
|
33038
|
+
const cleanBaseUrl = baseUrl.replace(/\/$/, "");
|
|
33039
|
+
const cleanSrc = src.startsWith("/") ? src : `/${src}`;
|
|
33040
|
+
return `${cleanBaseUrl}${cleanSrc}`;
|
|
33041
|
+
}
|
|
33042
|
+
function generatePostPageSchemas(options2) {
|
|
33043
|
+
const schemas = [];
|
|
33044
|
+
schemas.push(generateBlogPostingSchema(options2));
|
|
33045
|
+
schemas.push(generateBreadcrumbListSchema({
|
|
33046
|
+
site: options2.site,
|
|
33047
|
+
post: options2.post
|
|
33048
|
+
}));
|
|
33049
|
+
return schemas;
|
|
33050
|
+
}
|
|
33051
|
+
function generateHomePageSchemas(options2) {
|
|
33052
|
+
const schemas = [];
|
|
33053
|
+
schemas.push(generateWebSiteSchema(options2));
|
|
33054
|
+
schemas.push(generateOrganizationSchema(options2.site));
|
|
33055
|
+
return schemas;
|
|
33056
|
+
}
|
|
33057
|
+
|
|
32912
33058
|
// src/site-generator.ts
|
|
32913
33059
|
class SiteGenerator {
|
|
32914
33060
|
options;
|
|
@@ -33094,11 +33240,20 @@ class SiteGenerator {
|
|
|
33094
33240
|
const endIndex = startIndex + pageSize;
|
|
33095
33241
|
const paginatedPosts = this.site.posts.slice(startIndex, endIndex);
|
|
33096
33242
|
const pagination = this.createPagination(this.site.posts, page, pageSize, "/");
|
|
33243
|
+
let jsonLd = "";
|
|
33244
|
+
if (page === 1) {
|
|
33245
|
+
const schemas = generateHomePageSchemas({
|
|
33246
|
+
site: this.options.config
|
|
33247
|
+
});
|
|
33248
|
+
jsonLd = schemas.map((schema) => toScriptTag(schema)).join(`
|
|
33249
|
+
`);
|
|
33250
|
+
}
|
|
33097
33251
|
const pageHtml = import_nunjucks.default.render("index.njk", {
|
|
33098
33252
|
site: this.options.config,
|
|
33099
33253
|
posts: paginatedPosts,
|
|
33100
33254
|
tags: this.getSortedTags(this.options.config.maxTagsOnHomepage),
|
|
33101
|
-
pagination
|
|
33255
|
+
pagination,
|
|
33256
|
+
jsonLd
|
|
33102
33257
|
});
|
|
33103
33258
|
if (page === 1) {
|
|
33104
33259
|
await Bun.write(path5.join(this.options.outputDir, "index.html"), pageHtml);
|
|
@@ -33114,9 +33269,18 @@ class SiteGenerator {
|
|
|
33114
33269
|
const postPath = post.url.substring(1);
|
|
33115
33270
|
const postDir = path5.join(this.options.outputDir, postPath);
|
|
33116
33271
|
await ensureDir(postDir);
|
|
33272
|
+
const imageUrl = extractFirstImageUrl(post.html, this.options.config.baseUrl);
|
|
33273
|
+
const schemas = generatePostPageSchemas({
|
|
33274
|
+
post,
|
|
33275
|
+
site: this.options.config,
|
|
33276
|
+
imageUrl
|
|
33277
|
+
});
|
|
33278
|
+
const jsonLd = schemas.map((schema) => toScriptTag(schema)).join(`
|
|
33279
|
+
`);
|
|
33117
33280
|
const postHtml = import_nunjucks.default.render("post.njk", {
|
|
33118
33281
|
site: this.options.config,
|
|
33119
|
-
post
|
|
33282
|
+
post,
|
|
33283
|
+
jsonLd
|
|
33120
33284
|
});
|
|
33121
33285
|
await Bun.write(path5.join(postDir, "index.html"), postHtml);
|
|
33122
33286
|
}
|
|
@@ -33602,6 +33766,28 @@ class S3Uploader {
|
|
|
33602
33766
|
}
|
|
33603
33767
|
}
|
|
33604
33768
|
}
|
|
33769
|
+
async executeWithConcurrency(tasks, concurrency) {
|
|
33770
|
+
const results = [];
|
|
33771
|
+
const executing = [];
|
|
33772
|
+
for (const task of tasks) {
|
|
33773
|
+
const promise = task().then((result) => {
|
|
33774
|
+
results.push(result);
|
|
33775
|
+
const index = executing.indexOf(promise);
|
|
33776
|
+
if (index > -1)
|
|
33777
|
+
executing.splice(index, 1);
|
|
33778
|
+
}).catch((error) => {
|
|
33779
|
+
const index = executing.indexOf(promise);
|
|
33780
|
+
if (index > -1)
|
|
33781
|
+
executing.splice(index, 1);
|
|
33782
|
+
});
|
|
33783
|
+
executing.push(promise);
|
|
33784
|
+
if (executing.length >= concurrency) {
|
|
33785
|
+
await Promise.race(executing);
|
|
33786
|
+
}
|
|
33787
|
+
}
|
|
33788
|
+
await Promise.all(executing);
|
|
33789
|
+
return results;
|
|
33790
|
+
}
|
|
33605
33791
|
async uploadImages(imagesDir, minYear) {
|
|
33606
33792
|
console.log(`[S3] Uploading all images from ${imagesDir} to bucket ${this.s3Config.bucket}...`);
|
|
33607
33793
|
if (minYear) {
|
|
@@ -33609,59 +33795,30 @@ class S3Uploader {
|
|
|
33609
33795
|
}
|
|
33610
33796
|
const imageUrls = {};
|
|
33611
33797
|
try {
|
|
33612
|
-
console.log(`[S3] Checking if directory exists: ${imagesDir}`);
|
|
33613
|
-
try {
|
|
33614
|
-
const glob2 = new Bun.Glob("**/*");
|
|
33615
|
-
let hasContent = false;
|
|
33616
|
-
for await (const file of glob2.scan({
|
|
33617
|
-
cwd: imagesDir,
|
|
33618
|
-
absolute: false
|
|
33619
|
-
})) {
|
|
33620
|
-
hasContent = true;
|
|
33621
|
-
break;
|
|
33622
|
-
}
|
|
33623
|
-
if (!hasContent) {
|
|
33624
|
-
console.warn(`Directory exists but is empty: ${imagesDir}`);
|
|
33625
|
-
}
|
|
33626
|
-
console.log(`[S3] Directory exists and is accessible`);
|
|
33627
|
-
} catch (err) {
|
|
33628
|
-
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
33629
|
-
console.warn(`No images directory found at ${imagesDir}, skipping image upload. Error: ${errorMessage}`);
|
|
33630
|
-
return imageUrls;
|
|
33631
|
-
}
|
|
33632
33798
|
const glob = new Bun.Glob("**/*.{jpg,jpeg,png,gif,webp,svg}");
|
|
33633
33799
|
const files = [];
|
|
33634
33800
|
console.log(`[S3] Scanning directory ${imagesDir} for image files...`);
|
|
33635
33801
|
try {
|
|
33636
|
-
const
|
|
33637
|
-
const allFiles = [];
|
|
33638
|
-
for await (const file of dirGlob.scan({
|
|
33802
|
+
for await (const file of glob.scan({
|
|
33639
33803
|
cwd: imagesDir,
|
|
33640
33804
|
absolute: false
|
|
33641
33805
|
})) {
|
|
33642
|
-
|
|
33643
|
-
|
|
33644
|
-
|
|
33645
|
-
|
|
33646
|
-
|
|
33647
|
-
|
|
33648
|
-
|
|
33649
|
-
cwd: imagesDir,
|
|
33650
|
-
absolute: false
|
|
33651
|
-
})) {
|
|
33652
|
-
if (minYear) {
|
|
33653
|
-
const yearMatch = file.match(/^(\d{4})\//);
|
|
33654
|
-
if (yearMatch) {
|
|
33655
|
-
const fileYear = parseInt(yearMatch[1], 10);
|
|
33656
|
-
if (fileYear >= minYear) {
|
|
33657
|
-
console.log(`[S3] Found image file: ${file}`);
|
|
33658
|
-
files.push(file);
|
|
33806
|
+
if (minYear) {
|
|
33807
|
+
const yearMatch = file.match(/^(\d{4})\//);
|
|
33808
|
+
if (yearMatch) {
|
|
33809
|
+
const fileYear = parseInt(yearMatch[1], 10);
|
|
33810
|
+
if (fileYear >= minYear) {
|
|
33811
|
+
files.push(file);
|
|
33812
|
+
}
|
|
33659
33813
|
}
|
|
33814
|
+
} else {
|
|
33815
|
+
files.push(file);
|
|
33660
33816
|
}
|
|
33661
|
-
} else {
|
|
33662
|
-
console.log(`[S3] Found image file: ${file}`);
|
|
33663
|
-
files.push(file);
|
|
33664
33817
|
}
|
|
33818
|
+
} catch (err) {
|
|
33819
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
33820
|
+
console.warn(`Error scanning images directory: ${errorMessage}`);
|
|
33821
|
+
return imageUrls;
|
|
33665
33822
|
}
|
|
33666
33823
|
const imageFiles = files;
|
|
33667
33824
|
if (imageFiles.length === 0) {
|
|
@@ -33669,27 +33826,35 @@ class S3Uploader {
|
|
|
33669
33826
|
return imageUrls;
|
|
33670
33827
|
}
|
|
33671
33828
|
console.log(`Found ${imageFiles.length} images to upload`);
|
|
33672
|
-
|
|
33829
|
+
console.log(`[S3] Processing with 10 concurrent uploads...`);
|
|
33830
|
+
const concurrencyLimit = 10;
|
|
33831
|
+
let uploadedCount = 0;
|
|
33832
|
+
let failedCount = 0;
|
|
33833
|
+
const uploadTasks = imageFiles.map((imageFile) => async () => {
|
|
33673
33834
|
try {
|
|
33674
33835
|
const imagePath = path7.join(imagesDir, imageFile);
|
|
33675
33836
|
const filename = path7.basename(imagePath);
|
|
33676
|
-
console.log(`[S3] Uploading image ${imagePath} to S3 bucket ${this.s3Config.bucket}/${imageFile}...`);
|
|
33677
33837
|
const file = Bun.file(imagePath);
|
|
33678
33838
|
const contentType = file.type;
|
|
33679
|
-
if (process.env.BUNKI_DRY_RUN === "true") {
|
|
33680
|
-
console.log(`[S3] Dry run: would upload ${imageFile} with content type ${contentType}`);
|
|
33681
|
-
} else {
|
|
33839
|
+
if (process.env.BUNKI_DRY_RUN === "true") {} else {
|
|
33682
33840
|
const s3File = this.client.file(imageFile);
|
|
33683
33841
|
await s3File.write(file);
|
|
33684
33842
|
}
|
|
33685
33843
|
const imageUrl = this.getPublicUrl(imageFile);
|
|
33686
|
-
console.log(`[S3] Image uploaded to ${imageUrl}`);
|
|
33687
33844
|
imageUrls[imageFile] = imageUrl;
|
|
33845
|
+
uploadedCount++;
|
|
33846
|
+
if (uploadedCount % 10 === 0) {
|
|
33847
|
+
console.log(`[S3] Progress: ${uploadedCount}/${imageFiles.length} images uploaded`);
|
|
33848
|
+
}
|
|
33849
|
+
return { success: true, file: imageFile };
|
|
33688
33850
|
} catch (error) {
|
|
33689
|
-
|
|
33851
|
+
failedCount++;
|
|
33852
|
+
console.error(`[S3] Error uploading ${imageFile}:`, error);
|
|
33853
|
+
return { success: false, file: imageFile };
|
|
33690
33854
|
}
|
|
33691
|
-
}
|
|
33692
|
-
|
|
33855
|
+
});
|
|
33856
|
+
await this.executeWithConcurrency(uploadTasks, concurrencyLimit);
|
|
33857
|
+
console.log(`[S3] Upload complete: ${uploadedCount} succeeded, ${failedCount} failed out of ${imageFiles.length} images`);
|
|
33693
33858
|
return imageUrls;
|
|
33694
33859
|
} catch (error) {
|
|
33695
33860
|
console.error(`Error uploading images:`, error);
|
package/dist/index.js
CHANGED
|
@@ -30856,6 +30856,152 @@ function getDefaultCSSConfig() {
|
|
|
30856
30856
|
};
|
|
30857
30857
|
}
|
|
30858
30858
|
|
|
30859
|
+
// src/utils/json-ld.ts
|
|
30860
|
+
function generateOrganizationSchema(site) {
|
|
30861
|
+
const organization = {
|
|
30862
|
+
"@context": "https://schema.org",
|
|
30863
|
+
"@type": "Organization",
|
|
30864
|
+
name: site.title,
|
|
30865
|
+
url: site.baseUrl
|
|
30866
|
+
};
|
|
30867
|
+
if (site.description) {
|
|
30868
|
+
organization.description = site.description;
|
|
30869
|
+
}
|
|
30870
|
+
return organization;
|
|
30871
|
+
}
|
|
30872
|
+
function generateBlogPostingSchema(options2) {
|
|
30873
|
+
const { post, site, imageUrl, dateModified } = options2;
|
|
30874
|
+
const postUrl = `${site.baseUrl}${post.url}`;
|
|
30875
|
+
const blogPosting = {
|
|
30876
|
+
"@context": "https://schema.org",
|
|
30877
|
+
"@type": "BlogPosting",
|
|
30878
|
+
headline: post.title,
|
|
30879
|
+
description: post.excerpt,
|
|
30880
|
+
url: postUrl,
|
|
30881
|
+
mainEntityOfPage: {
|
|
30882
|
+
"@type": "WebPage",
|
|
30883
|
+
"@id": postUrl
|
|
30884
|
+
},
|
|
30885
|
+
datePublished: post.date,
|
|
30886
|
+
dateModified: dateModified || post.date
|
|
30887
|
+
};
|
|
30888
|
+
if (site.authorName) {
|
|
30889
|
+
blogPosting.author = {
|
|
30890
|
+
"@type": "Person",
|
|
30891
|
+
name: site.authorName,
|
|
30892
|
+
...site.authorEmail && { email: site.authorEmail }
|
|
30893
|
+
};
|
|
30894
|
+
}
|
|
30895
|
+
blogPosting.publisher = {
|
|
30896
|
+
"@type": "Organization",
|
|
30897
|
+
name: site.title,
|
|
30898
|
+
url: site.baseUrl
|
|
30899
|
+
};
|
|
30900
|
+
if (imageUrl) {
|
|
30901
|
+
blogPosting.image = imageUrl;
|
|
30902
|
+
}
|
|
30903
|
+
if (post.tags && post.tags.length > 0) {
|
|
30904
|
+
blogPosting.keywords = post.tags.join(", ");
|
|
30905
|
+
}
|
|
30906
|
+
if (post.tags && post.tags.length > 0) {
|
|
30907
|
+
blogPosting.articleSection = post.tags[0];
|
|
30908
|
+
}
|
|
30909
|
+
if (post.content) {
|
|
30910
|
+
const wordCount = post.content.split(/\s+/).length;
|
|
30911
|
+
blogPosting.wordCount = wordCount;
|
|
30912
|
+
}
|
|
30913
|
+
blogPosting.inLanguage = site.rssLanguage || "en-US";
|
|
30914
|
+
return blogPosting;
|
|
30915
|
+
}
|
|
30916
|
+
function generateWebSiteSchema(options2) {
|
|
30917
|
+
const { site } = options2;
|
|
30918
|
+
const webSite = {
|
|
30919
|
+
"@context": "https://schema.org",
|
|
30920
|
+
"@type": "WebSite",
|
|
30921
|
+
name: site.title,
|
|
30922
|
+
url: site.baseUrl
|
|
30923
|
+
};
|
|
30924
|
+
if (site.description) {
|
|
30925
|
+
webSite.description = site.description;
|
|
30926
|
+
}
|
|
30927
|
+
webSite.potentialAction = {
|
|
30928
|
+
"@type": "SearchAction",
|
|
30929
|
+
target: {
|
|
30930
|
+
"@type": "EntryPoint",
|
|
30931
|
+
urlTemplate: `${site.baseUrl}/search?q={search_term_string}`
|
|
30932
|
+
},
|
|
30933
|
+
"query-input": "required name=search_term_string"
|
|
30934
|
+
};
|
|
30935
|
+
return webSite;
|
|
30936
|
+
}
|
|
30937
|
+
function generateBreadcrumbListSchema(options2) {
|
|
30938
|
+
const { site, post, items } = options2;
|
|
30939
|
+
const breadcrumbs = {
|
|
30940
|
+
"@context": "https://schema.org",
|
|
30941
|
+
"@type": "BreadcrumbList",
|
|
30942
|
+
itemListElement: []
|
|
30943
|
+
};
|
|
30944
|
+
if (items && items.length > 0) {
|
|
30945
|
+
breadcrumbs.itemListElement = items.map((item, index) => ({
|
|
30946
|
+
"@type": "ListItem",
|
|
30947
|
+
position: index + 1,
|
|
30948
|
+
name: item.name,
|
|
30949
|
+
item: item.url
|
|
30950
|
+
}));
|
|
30951
|
+
return breadcrumbs;
|
|
30952
|
+
}
|
|
30953
|
+
const homeItem = {
|
|
30954
|
+
"@type": "ListItem",
|
|
30955
|
+
position: 1,
|
|
30956
|
+
name: "Home",
|
|
30957
|
+
item: site.baseUrl
|
|
30958
|
+
};
|
|
30959
|
+
breadcrumbs.itemListElement.push(homeItem);
|
|
30960
|
+
if (post) {
|
|
30961
|
+
breadcrumbs.itemListElement.push({
|
|
30962
|
+
"@type": "ListItem",
|
|
30963
|
+
position: 2,
|
|
30964
|
+
name: post.title,
|
|
30965
|
+
item: `${site.baseUrl}${post.url}`
|
|
30966
|
+
});
|
|
30967
|
+
}
|
|
30968
|
+
return breadcrumbs;
|
|
30969
|
+
}
|
|
30970
|
+
function toScriptTag(jsonLd) {
|
|
30971
|
+
const json2 = JSON.stringify(jsonLd, null, 2);
|
|
30972
|
+
return `<script type="application/ld+json">
|
|
30973
|
+
${json2}
|
|
30974
|
+
</script>`;
|
|
30975
|
+
}
|
|
30976
|
+
function extractFirstImageUrl(html, baseUrl) {
|
|
30977
|
+
const imgMatch = html.match(/<img[^>]+src=["']([^"']+)["']/i);
|
|
30978
|
+
if (!imgMatch || !imgMatch[1]) {
|
|
30979
|
+
return;
|
|
30980
|
+
}
|
|
30981
|
+
const src = imgMatch[1];
|
|
30982
|
+
if (src.startsWith("http://") || src.startsWith("https://")) {
|
|
30983
|
+
return src;
|
|
30984
|
+
}
|
|
30985
|
+
const cleanBaseUrl = baseUrl.replace(/\/$/, "");
|
|
30986
|
+
const cleanSrc = src.startsWith("/") ? src : `/${src}`;
|
|
30987
|
+
return `${cleanBaseUrl}${cleanSrc}`;
|
|
30988
|
+
}
|
|
30989
|
+
function generatePostPageSchemas(options2) {
|
|
30990
|
+
const schemas = [];
|
|
30991
|
+
schemas.push(generateBlogPostingSchema(options2));
|
|
30992
|
+
schemas.push(generateBreadcrumbListSchema({
|
|
30993
|
+
site: options2.site,
|
|
30994
|
+
post: options2.post
|
|
30995
|
+
}));
|
|
30996
|
+
return schemas;
|
|
30997
|
+
}
|
|
30998
|
+
function generateHomePageSchemas(options2) {
|
|
30999
|
+
const schemas = [];
|
|
31000
|
+
schemas.push(generateWebSiteSchema(options2));
|
|
31001
|
+
schemas.push(generateOrganizationSchema(options2.site));
|
|
31002
|
+
return schemas;
|
|
31003
|
+
}
|
|
31004
|
+
|
|
30859
31005
|
// src/site-generator.ts
|
|
30860
31006
|
class SiteGenerator {
|
|
30861
31007
|
options;
|
|
@@ -31041,11 +31187,20 @@ class SiteGenerator {
|
|
|
31041
31187
|
const endIndex = startIndex + pageSize;
|
|
31042
31188
|
const paginatedPosts = this.site.posts.slice(startIndex, endIndex);
|
|
31043
31189
|
const pagination = this.createPagination(this.site.posts, page, pageSize, "/");
|
|
31190
|
+
let jsonLd = "";
|
|
31191
|
+
if (page === 1) {
|
|
31192
|
+
const schemas = generateHomePageSchemas({
|
|
31193
|
+
site: this.options.config
|
|
31194
|
+
});
|
|
31195
|
+
jsonLd = schemas.map((schema) => toScriptTag(schema)).join(`
|
|
31196
|
+
`);
|
|
31197
|
+
}
|
|
31044
31198
|
const pageHtml = import_nunjucks.default.render("index.njk", {
|
|
31045
31199
|
site: this.options.config,
|
|
31046
31200
|
posts: paginatedPosts,
|
|
31047
31201
|
tags: this.getSortedTags(this.options.config.maxTagsOnHomepage),
|
|
31048
|
-
pagination
|
|
31202
|
+
pagination,
|
|
31203
|
+
jsonLd
|
|
31049
31204
|
});
|
|
31050
31205
|
if (page === 1) {
|
|
31051
31206
|
await Bun.write(path5.join(this.options.outputDir, "index.html"), pageHtml);
|
|
@@ -31061,9 +31216,18 @@ class SiteGenerator {
|
|
|
31061
31216
|
const postPath = post.url.substring(1);
|
|
31062
31217
|
const postDir = path5.join(this.options.outputDir, postPath);
|
|
31063
31218
|
await ensureDir(postDir);
|
|
31219
|
+
const imageUrl = extractFirstImageUrl(post.html, this.options.config.baseUrl);
|
|
31220
|
+
const schemas = generatePostPageSchemas({
|
|
31221
|
+
post,
|
|
31222
|
+
site: this.options.config,
|
|
31223
|
+
imageUrl
|
|
31224
|
+
});
|
|
31225
|
+
const jsonLd = schemas.map((schema) => toScriptTag(schema)).join(`
|
|
31226
|
+
`);
|
|
31064
31227
|
const postHtml = import_nunjucks.default.render("post.njk", {
|
|
31065
31228
|
site: this.options.config,
|
|
31066
|
-
post
|
|
31229
|
+
post,
|
|
31230
|
+
jsonLd
|
|
31067
31231
|
});
|
|
31068
31232
|
await Bun.write(path5.join(postDir, "index.html"), postHtml);
|
|
31069
31233
|
}
|
|
@@ -31509,6 +31673,28 @@ class S3Uploader {
|
|
|
31509
31673
|
}
|
|
31510
31674
|
}
|
|
31511
31675
|
}
|
|
31676
|
+
async executeWithConcurrency(tasks, concurrency) {
|
|
31677
|
+
const results = [];
|
|
31678
|
+
const executing = [];
|
|
31679
|
+
for (const task of tasks) {
|
|
31680
|
+
const promise = task().then((result) => {
|
|
31681
|
+
results.push(result);
|
|
31682
|
+
const index = executing.indexOf(promise);
|
|
31683
|
+
if (index > -1)
|
|
31684
|
+
executing.splice(index, 1);
|
|
31685
|
+
}).catch((error) => {
|
|
31686
|
+
const index = executing.indexOf(promise);
|
|
31687
|
+
if (index > -1)
|
|
31688
|
+
executing.splice(index, 1);
|
|
31689
|
+
});
|
|
31690
|
+
executing.push(promise);
|
|
31691
|
+
if (executing.length >= concurrency) {
|
|
31692
|
+
await Promise.race(executing);
|
|
31693
|
+
}
|
|
31694
|
+
}
|
|
31695
|
+
await Promise.all(executing);
|
|
31696
|
+
return results;
|
|
31697
|
+
}
|
|
31512
31698
|
async uploadImages(imagesDir, minYear) {
|
|
31513
31699
|
console.log(`[S3] Uploading all images from ${imagesDir} to bucket ${this.s3Config.bucket}...`);
|
|
31514
31700
|
if (minYear) {
|
|
@@ -31516,59 +31702,30 @@ class S3Uploader {
|
|
|
31516
31702
|
}
|
|
31517
31703
|
const imageUrls = {};
|
|
31518
31704
|
try {
|
|
31519
|
-
console.log(`[S3] Checking if directory exists: ${imagesDir}`);
|
|
31520
|
-
try {
|
|
31521
|
-
const glob2 = new Bun.Glob("**/*");
|
|
31522
|
-
let hasContent = false;
|
|
31523
|
-
for await (const file of glob2.scan({
|
|
31524
|
-
cwd: imagesDir,
|
|
31525
|
-
absolute: false
|
|
31526
|
-
})) {
|
|
31527
|
-
hasContent = true;
|
|
31528
|
-
break;
|
|
31529
|
-
}
|
|
31530
|
-
if (!hasContent) {
|
|
31531
|
-
console.warn(`Directory exists but is empty: ${imagesDir}`);
|
|
31532
|
-
}
|
|
31533
|
-
console.log(`[S3] Directory exists and is accessible`);
|
|
31534
|
-
} catch (err) {
|
|
31535
|
-
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
31536
|
-
console.warn(`No images directory found at ${imagesDir}, skipping image upload. Error: ${errorMessage}`);
|
|
31537
|
-
return imageUrls;
|
|
31538
|
-
}
|
|
31539
31705
|
const glob = new Bun.Glob("**/*.{jpg,jpeg,png,gif,webp,svg}");
|
|
31540
31706
|
const files = [];
|
|
31541
31707
|
console.log(`[S3] Scanning directory ${imagesDir} for image files...`);
|
|
31542
31708
|
try {
|
|
31543
|
-
const
|
|
31544
|
-
const allFiles = [];
|
|
31545
|
-
for await (const file of dirGlob.scan({
|
|
31709
|
+
for await (const file of glob.scan({
|
|
31546
31710
|
cwd: imagesDir,
|
|
31547
31711
|
absolute: false
|
|
31548
31712
|
})) {
|
|
31549
|
-
|
|
31550
|
-
|
|
31551
|
-
|
|
31552
|
-
|
|
31553
|
-
|
|
31554
|
-
|
|
31555
|
-
|
|
31556
|
-
cwd: imagesDir,
|
|
31557
|
-
absolute: false
|
|
31558
|
-
})) {
|
|
31559
|
-
if (minYear) {
|
|
31560
|
-
const yearMatch = file.match(/^(\d{4})\//);
|
|
31561
|
-
if (yearMatch) {
|
|
31562
|
-
const fileYear = parseInt(yearMatch[1], 10);
|
|
31563
|
-
if (fileYear >= minYear) {
|
|
31564
|
-
console.log(`[S3] Found image file: ${file}`);
|
|
31565
|
-
files.push(file);
|
|
31713
|
+
if (minYear) {
|
|
31714
|
+
const yearMatch = file.match(/^(\d{4})\//);
|
|
31715
|
+
if (yearMatch) {
|
|
31716
|
+
const fileYear = parseInt(yearMatch[1], 10);
|
|
31717
|
+
if (fileYear >= minYear) {
|
|
31718
|
+
files.push(file);
|
|
31719
|
+
}
|
|
31566
31720
|
}
|
|
31721
|
+
} else {
|
|
31722
|
+
files.push(file);
|
|
31567
31723
|
}
|
|
31568
|
-
} else {
|
|
31569
|
-
console.log(`[S3] Found image file: ${file}`);
|
|
31570
|
-
files.push(file);
|
|
31571
31724
|
}
|
|
31725
|
+
} catch (err) {
|
|
31726
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
31727
|
+
console.warn(`Error scanning images directory: ${errorMessage}`);
|
|
31728
|
+
return imageUrls;
|
|
31572
31729
|
}
|
|
31573
31730
|
const imageFiles = files;
|
|
31574
31731
|
if (imageFiles.length === 0) {
|
|
@@ -31576,27 +31733,35 @@ class S3Uploader {
|
|
|
31576
31733
|
return imageUrls;
|
|
31577
31734
|
}
|
|
31578
31735
|
console.log(`Found ${imageFiles.length} images to upload`);
|
|
31579
|
-
|
|
31736
|
+
console.log(`[S3] Processing with 10 concurrent uploads...`);
|
|
31737
|
+
const concurrencyLimit = 10;
|
|
31738
|
+
let uploadedCount = 0;
|
|
31739
|
+
let failedCount = 0;
|
|
31740
|
+
const uploadTasks = imageFiles.map((imageFile) => async () => {
|
|
31580
31741
|
try {
|
|
31581
31742
|
const imagePath = path6.join(imagesDir, imageFile);
|
|
31582
31743
|
const filename = path6.basename(imagePath);
|
|
31583
|
-
console.log(`[S3] Uploading image ${imagePath} to S3 bucket ${this.s3Config.bucket}/${imageFile}...`);
|
|
31584
31744
|
const file = Bun.file(imagePath);
|
|
31585
31745
|
const contentType = file.type;
|
|
31586
|
-
if (process.env.BUNKI_DRY_RUN === "true") {
|
|
31587
|
-
console.log(`[S3] Dry run: would upload ${imageFile} with content type ${contentType}`);
|
|
31588
|
-
} else {
|
|
31746
|
+
if (process.env.BUNKI_DRY_RUN === "true") {} else {
|
|
31589
31747
|
const s3File = this.client.file(imageFile);
|
|
31590
31748
|
await s3File.write(file);
|
|
31591
31749
|
}
|
|
31592
31750
|
const imageUrl = this.getPublicUrl(imageFile);
|
|
31593
|
-
console.log(`[S3] Image uploaded to ${imageUrl}`);
|
|
31594
31751
|
imageUrls[imageFile] = imageUrl;
|
|
31752
|
+
uploadedCount++;
|
|
31753
|
+
if (uploadedCount % 10 === 0) {
|
|
31754
|
+
console.log(`[S3] Progress: ${uploadedCount}/${imageFiles.length} images uploaded`);
|
|
31755
|
+
}
|
|
31756
|
+
return { success: true, file: imageFile };
|
|
31595
31757
|
} catch (error) {
|
|
31596
|
-
|
|
31758
|
+
failedCount++;
|
|
31759
|
+
console.error(`[S3] Error uploading ${imageFile}:`, error);
|
|
31760
|
+
return { success: false, file: imageFile };
|
|
31597
31761
|
}
|
|
31598
|
-
}
|
|
31599
|
-
|
|
31762
|
+
});
|
|
31763
|
+
await this.executeWithConcurrency(uploadTasks, concurrencyLimit);
|
|
31764
|
+
console.log(`[S3] Upload complete: ${uploadedCount} succeeded, ${failedCount} failed out of ${imageFiles.length} images`);
|
|
31600
31765
|
return imageUrls;
|
|
31601
31766
|
} catch (error) {
|
|
31602
31767
|
console.error(`Error uploading images:`, error);
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON-LD (JavaScript Object Notation for Linked Data) utility functions
|
|
3
|
+
* for generating structured data markup for SEO optimization.
|
|
4
|
+
*
|
|
5
|
+
* This module provides functions to generate Schema.org structured data
|
|
6
|
+
* in JSON-LD format, which helps search engines better understand your content.
|
|
7
|
+
*
|
|
8
|
+
* Supported schema types:
|
|
9
|
+
* - BlogPosting: For individual blog posts/articles
|
|
10
|
+
* - WebSite: For the homepage/website metadata
|
|
11
|
+
* - BreadcrumbList: For navigation breadcrumbs
|
|
12
|
+
* - Organization: For publisher/organization information
|
|
13
|
+
* - Person: For author information
|
|
14
|
+
*
|
|
15
|
+
* @see https://schema.org/
|
|
16
|
+
* @see https://developers.google.com/search/docs/appearance/structured-data/intro-structured-data
|
|
17
|
+
*/
|
|
18
|
+
import type { Post, SiteConfig } from "../types.js";
|
|
19
|
+
/**
|
|
20
|
+
* Base Schema.org Thing type
|
|
21
|
+
*/
|
|
22
|
+
interface SchemaOrgThing {
|
|
23
|
+
"@context": "https://schema.org";
|
|
24
|
+
"@type": string;
|
|
25
|
+
[key: string]: any;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Options for generating BlogPosting JSON-LD
|
|
29
|
+
*/
|
|
30
|
+
export interface BlogPostingOptions {
|
|
31
|
+
/** The post object */
|
|
32
|
+
post: Post;
|
|
33
|
+
/** Site configuration */
|
|
34
|
+
site: SiteConfig;
|
|
35
|
+
/** Optional image URL for the post (first image extracted from content) */
|
|
36
|
+
imageUrl?: string;
|
|
37
|
+
/** Optional date modified (defaults to date published) */
|
|
38
|
+
dateModified?: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Options for generating WebSite JSON-LD
|
|
42
|
+
*/
|
|
43
|
+
export interface WebSiteOptions {
|
|
44
|
+
/** Site configuration */
|
|
45
|
+
site: SiteConfig;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Options for generating BreadcrumbList JSON-LD
|
|
49
|
+
*/
|
|
50
|
+
export interface BreadcrumbListOptions {
|
|
51
|
+
/** Site configuration */
|
|
52
|
+
site: SiteConfig;
|
|
53
|
+
/** Current post (optional, for post pages) */
|
|
54
|
+
post?: Post;
|
|
55
|
+
/** Custom breadcrumb items */
|
|
56
|
+
items?: Array<{
|
|
57
|
+
name: string;
|
|
58
|
+
url: string;
|
|
59
|
+
}>;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Generates a Person schema for author information
|
|
63
|
+
*
|
|
64
|
+
* @param name - Author's name
|
|
65
|
+
* @param email - Author's email (optional)
|
|
66
|
+
* @returns Person schema object
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* const author = generatePersonSchema("John Doe", "john@example.com");
|
|
70
|
+
* // Returns: { "@type": "Person", "name": "John Doe", "email": "john@example.com" }
|
|
71
|
+
*/
|
|
72
|
+
export declare function generatePersonSchema(name: string, email?: string): SchemaOrgThing;
|
|
73
|
+
/**
|
|
74
|
+
* Generates an Organization schema for publisher information
|
|
75
|
+
*
|
|
76
|
+
* @param site - Site configuration
|
|
77
|
+
* @returns Organization schema object
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* const org = generateOrganizationSchema({ title: "My Blog", baseUrl: "https://example.com" });
|
|
81
|
+
* // Returns: { "@type": "Organization", "name": "My Blog", "url": "https://example.com" }
|
|
82
|
+
*/
|
|
83
|
+
export declare function generateOrganizationSchema(site: SiteConfig): SchemaOrgThing;
|
|
84
|
+
/**
|
|
85
|
+
* Generates BlogPosting structured data for a blog post
|
|
86
|
+
*
|
|
87
|
+
* This is the primary schema for individual blog posts/articles.
|
|
88
|
+
* It provides search engines with detailed information about the post
|
|
89
|
+
* including title, author, publication date, content, and more.
|
|
90
|
+
*
|
|
91
|
+
* @param options - BlogPosting generation options
|
|
92
|
+
* @returns BlogPosting schema as JSON-LD object
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* const jsonLd = generateBlogPostingSchema({
|
|
96
|
+
* post: { title: "Hello World", date: "2025-01-15T10:00:00Z", ... },
|
|
97
|
+
* site: { title: "My Blog", baseUrl: "https://example.com", ... }
|
|
98
|
+
* });
|
|
99
|
+
*
|
|
100
|
+
* @see https://schema.org/BlogPosting
|
|
101
|
+
*/
|
|
102
|
+
export declare function generateBlogPostingSchema(options: BlogPostingOptions): SchemaOrgThing;
|
|
103
|
+
/**
|
|
104
|
+
* Generates WebSite structured data for the homepage
|
|
105
|
+
*
|
|
106
|
+
* This schema provides search engines with information about the website
|
|
107
|
+
* itself, including name, description, and URL.
|
|
108
|
+
*
|
|
109
|
+
* @param options - WebSite generation options
|
|
110
|
+
* @returns WebSite schema as JSON-LD object
|
|
111
|
+
*
|
|
112
|
+
* @example
|
|
113
|
+
* const jsonLd = generateWebSiteSchema({
|
|
114
|
+
* site: { title: "My Blog", baseUrl: "https://example.com", description: "..." }
|
|
115
|
+
* });
|
|
116
|
+
*
|
|
117
|
+
* @see https://schema.org/WebSite
|
|
118
|
+
*/
|
|
119
|
+
export declare function generateWebSiteSchema(options: WebSiteOptions): SchemaOrgThing;
|
|
120
|
+
/**
|
|
121
|
+
* Generates BreadcrumbList structured data for navigation
|
|
122
|
+
*
|
|
123
|
+
* Breadcrumbs help search engines understand the site's hierarchy
|
|
124
|
+
* and can appear in search results.
|
|
125
|
+
*
|
|
126
|
+
* @param options - BreadcrumbList generation options
|
|
127
|
+
* @returns BreadcrumbList schema as JSON-LD object
|
|
128
|
+
*
|
|
129
|
+
* @example
|
|
130
|
+
* // For a blog post
|
|
131
|
+
* const jsonLd = generateBreadcrumbListSchema({
|
|
132
|
+
* site: { title: "My Blog", baseUrl: "https://example.com" },
|
|
133
|
+
* post: { title: "Hello World", url: "/2025/hello-world/" }
|
|
134
|
+
* });
|
|
135
|
+
*
|
|
136
|
+
* @see https://schema.org/BreadcrumbList
|
|
137
|
+
*/
|
|
138
|
+
export declare function generateBreadcrumbListSchema(options: BreadcrumbListOptions): SchemaOrgThing;
|
|
139
|
+
/**
|
|
140
|
+
* Converts a JSON-LD object to an HTML script tag string
|
|
141
|
+
*
|
|
142
|
+
* This helper function serializes the JSON-LD object and wraps it
|
|
143
|
+
* in a script tag for inclusion in HTML templates.
|
|
144
|
+
*
|
|
145
|
+
* @param jsonLd - The JSON-LD object to convert
|
|
146
|
+
* @returns HTML script tag string
|
|
147
|
+
*
|
|
148
|
+
* @example
|
|
149
|
+
* const schema = generateBlogPostingSchema({ ... });
|
|
150
|
+
* const scriptTag = toScriptTag(schema);
|
|
151
|
+
* // Returns: '<script type="application/ld+json">{"@context":"https://schema.org",...}</script>'
|
|
152
|
+
*/
|
|
153
|
+
export declare function toScriptTag(jsonLd: SchemaOrgThing): string;
|
|
154
|
+
/**
|
|
155
|
+
* Extracts the first image URL from HTML content
|
|
156
|
+
*
|
|
157
|
+
* Searches for the first <img> tag in HTML content and returns its src attribute.
|
|
158
|
+
* This is useful for automatically finding a representative image for BlogPosting schema.
|
|
159
|
+
*
|
|
160
|
+
* @param html - HTML content to search
|
|
161
|
+
* @param baseUrl - Base URL to prepend to relative image URLs
|
|
162
|
+
* @returns Full image URL or undefined if no image found
|
|
163
|
+
*
|
|
164
|
+
* @example
|
|
165
|
+
* const imageUrl = extractFirstImageUrl(
|
|
166
|
+
* '<p>Text</p><img src="/img/photo.jpg" alt="Photo">',
|
|
167
|
+
* 'https://example.com'
|
|
168
|
+
* );
|
|
169
|
+
* // Returns: 'https://example.com/img/photo.jpg'
|
|
170
|
+
*/
|
|
171
|
+
export declare function extractFirstImageUrl(html: string, baseUrl: string): string | undefined;
|
|
172
|
+
/**
|
|
173
|
+
* Generates multiple JSON-LD schemas for a blog post page
|
|
174
|
+
*
|
|
175
|
+
* This is a convenience function that generates all relevant schemas
|
|
176
|
+
* for a typical blog post page: BlogPosting, BreadcrumbList, and Organization.
|
|
177
|
+
*
|
|
178
|
+
* @param options - BlogPosting generation options
|
|
179
|
+
* @returns Array of JSON-LD schemas
|
|
180
|
+
*
|
|
181
|
+
* @example
|
|
182
|
+
* const schemas = generatePostPageSchemas({
|
|
183
|
+
* post: { ... },
|
|
184
|
+
* site: { ... }
|
|
185
|
+
* });
|
|
186
|
+
* // Returns: [BlogPosting, BreadcrumbList]
|
|
187
|
+
*/
|
|
188
|
+
export declare function generatePostPageSchemas(options: BlogPostingOptions): SchemaOrgThing[];
|
|
189
|
+
/**
|
|
190
|
+
* Generates multiple JSON-LD schemas for the homepage
|
|
191
|
+
*
|
|
192
|
+
* This is a convenience function that generates all relevant schemas
|
|
193
|
+
* for the homepage: WebSite and Organization.
|
|
194
|
+
*
|
|
195
|
+
* @param options - WebSite generation options
|
|
196
|
+
* @returns Array of JSON-LD schemas
|
|
197
|
+
*
|
|
198
|
+
* @example
|
|
199
|
+
* const schemas = generateHomePageSchemas({
|
|
200
|
+
* site: { title: "My Blog", baseUrl: "https://example.com", ... }
|
|
201
|
+
* });
|
|
202
|
+
* // Returns: [WebSite, Organization]
|
|
203
|
+
*/
|
|
204
|
+
export declare function generateHomePageSchemas(options: WebSiteOptions): SchemaOrgThing[];
|
|
205
|
+
export {};
|
|
@@ -13,6 +13,12 @@ export declare class S3Uploader implements Uploader, ImageUploader {
|
|
|
13
13
|
* @returns The public URL for the file
|
|
14
14
|
*/
|
|
15
15
|
private getPublicUrl;
|
|
16
|
+
/**
|
|
17
|
+
* Execute async tasks with concurrency limit
|
|
18
|
+
* @param tasks Array of task functions that return promises
|
|
19
|
+
* @param concurrency Maximum number of concurrent tasks
|
|
20
|
+
*/
|
|
21
|
+
private executeWithConcurrency;
|
|
16
22
|
uploadImages(imagesDir: string, minYear?: number): Promise<Record<string, string>>;
|
|
17
23
|
}
|
|
18
24
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bunki",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
},
|
|
48
48
|
"homepage": "https://github.com/kahwee/bunki#readme",
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"commander": "^14.0.
|
|
50
|
+
"commander": "^14.0.2",
|
|
51
51
|
"gray-matter": "^4.0.3",
|
|
52
52
|
"highlight.js": "^11.11.1",
|
|
53
53
|
"marked": "^16.4.1",
|
|
@@ -59,15 +59,15 @@
|
|
|
59
59
|
"slugify": "^1.6.6"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
|
-
"@tailwindcss/postcss": "^4.1.
|
|
62
|
+
"@tailwindcss/postcss": "^4.1.16",
|
|
63
63
|
"@types/nunjucks": "^3.2.6",
|
|
64
64
|
"@types/sanitize-html": "^2.16.0",
|
|
65
|
-
"autoprefixer": "^10.4.
|
|
66
|
-
"bun-types": "^1.3.
|
|
65
|
+
"autoprefixer": "^10.4.21",
|
|
66
|
+
"bun-types": "^1.3.2",
|
|
67
67
|
"husky": "^9.1.7",
|
|
68
|
-
"lint-staged": "^16.2.
|
|
68
|
+
"lint-staged": "^16.2.6",
|
|
69
69
|
"prettier": "^3.6.2",
|
|
70
|
-
"tailwindcss": "^4.1.
|
|
70
|
+
"tailwindcss": "^4.1.16",
|
|
71
71
|
"typescript": "^5.9.3"
|
|
72
72
|
},
|
|
73
73
|
"peerDependencies": {
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"README.md"
|
|
85
85
|
],
|
|
86
86
|
"engines": {
|
|
87
|
-
"bun": ">=1.3.
|
|
87
|
+
"bun": ">=1.3.2",
|
|
88
88
|
"node": ">=18.0.0"
|
|
89
89
|
},
|
|
90
90
|
"engineStrict": true,
|