├── example.png ├── electron ├── izzy.png └── index.html ├── example-project ├── templates │ ├── partials │ │ ├── content.hbs │ │ ├── navbar.hbs │ │ ├── post-list.hbs │ │ ├── footer.hbs │ │ ├── head.hbs │ │ └── bsky-comments.hbs │ ├── default.html │ ├── archive.html │ ├── index.html │ └── post.html ├── content │ ├── feed.md │ ├── posts │ │ ├── ~default.yaml │ │ ├── 4-building.md │ │ ├── 2-getting-started.md │ │ ├── 5-data.md │ │ ├── 3-folders.md │ │ ├── 1-intro.md │ │ ├── 6-beyond.md │ │ └── 7-bsky-comments.md │ ├── archive.md │ ├── index.md │ └── about.md ├── static │ ├── images │ │ ├── header.png │ │ ├── dbz_goku.gif │ │ ├── favicon.ico │ │ ├── bg_diamond.png │ │ └── bsky_post.png │ └── style │ │ └── style.css └── bimbo.yaml ├── .favorites.json ├── preload.js ├── .github └── workflows │ ├── bin-build.yml │ ├── deploy-example.yml │ ├── build.yml │ └── bin-test.yml ├── jsconfig.json ├── .vscode └── launch.json ├── compile.sh ├── action.yml ├── README.md ├── package.json ├── .gitignore ├── bluesky.ts └── main.js /example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iznaut/bimbo/HEAD/example.png -------------------------------------------------------------------------------- /electron/izzy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iznaut/bimbo/HEAD/electron/izzy.png -------------------------------------------------------------------------------- /example-project/templates/partials/content.hbs: -------------------------------------------------------------------------------- 1 |
14 |
15 |
--------------------------------------------------------------------------------
/example-project/content/posts/2-getting-started.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: getting started
3 | date: 2025-02-02
4 | draft: false
5 | ---
6 |
7 | Bimbo has apps for Windows, Mac, and Linux, though i've only personally tested the Mac[^1] one!
8 |
9 | you can [download it here](https://github.com/iznaut/bimbo/releases/latest), along with the `example.zip`, which contains the source files used to make the blog you're reading right now!
10 |
11 | make sure you have both the app file and the zip in the same (preferably empty) folder[^2] on your computer. now all you need to do is run the app!
12 |
13 | if everything is working properly, you should see a terminal window pop up. it'll do some initial setup the first time (in this case, unpacking the zip file) and then display "Ready for changes" just before opening your browser with a local version of the website! now you can make changes and see them reflected right away[^3]
14 |
15 | [^1]: specifically, the Apple Silicon version, which you can use if you have a newer device using their M-series of processors. if you're not sure, you can try using the Intel one
16 | [^2]: alternatively, you can tell Bimbo to look at a different folder by supplying a `--path` argument, but you don't really need to worry about that unless you want to make multiple websites
17 | [^3]: this _should_ be instantaneous, to the point the page will reload automatically when it detects changes, but this feature is currently broken for some reason i have yet to understand. you'll have to refresh manually for now, sorry about that
--------------------------------------------------------------------------------
/action.yml:
--------------------------------------------------------------------------------
1 | name: 'Bimbo Build'
2 | description: 'build your Bimbo website'
3 |
4 | branding:
5 | icon: 'shopping-cart'
6 | color: 'purple'
7 |
8 | inputs:
9 | bimbo-path:
10 | description: 'the path to your Bimbo project'
11 | required: false
12 | default: './'
13 | dev-mode:
14 | description: 'use [local] code or [bleeding-edge] (clone from main branch)'
15 | required: false
16 | default: 'false' # false | local | bleeding-edge
17 |
18 | runs:
19 | using: 'composite'
20 | steps:
21 | - name: Clone Bimbo
22 | run: |
23 | if [ ${{ inputs.dev-mode }} == 'local' ]; then
24 | echo "we have Bimbo at home, skipping git clone"
25 | elif [ ${{ inputs.dev-mode }} == 'bleeding-edge' ]; then
26 | echo "cloning main branch"
27 | git clone --depth 1 https://github.com/iznaut/bimbo.git
28 | else
29 | LATEST_RELEASE=$(curl --silent "https://api.github.com/repos/iznaut/bimbo/releases/latest" | jq -r .tag_name)
30 | echo "cloning release ${LATEST_RELEASE}"
31 | git clone --depth 1 https://github.com/iznaut/bimbo.git --branch "${LATEST_RELEASE}"
32 | fi
33 | shell: bash
34 |
35 | - name: Setup bun
36 | uses: oven-sh/setup-bun@v2
37 |
38 | - name: Build website
39 | run: |
40 | if [ ${{ inputs.dev-mode }} == 'local' ]; then
41 | echo "using local Bimbo"
42 | bun install
43 | bun main.js --path ./example-project --deploy
44 | else
45 | echo "using cloned Bimbo"
46 | bun install --cwd="./bimbo"
47 | bun ./bimbo/main.js --path ../ --deploy
48 | fi
49 | shell: bash
50 |
51 | - name: Commit and push changes
52 | uses: stefanzweifel/git-auto-commit-action@v5
53 | with:
54 | commit_message: Update content metadata post-build
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Bimbo SSG
2 | 
3 | [example site here](https://bimbo.nekoweb.org)
4 |
5 | i wanted a blog. so i tried some Static Site Generator tools and they all seemed more complicated than i needed
6 |
7 | i liked the vibe of Zonelets, but missed some of the nice stuff you get with an SSG
8 |
9 | what's a girl to do when she's too smart for the simple tool but too dumb for the advanced tools? she makes her own dumb tool
10 |
11 | # how do i use this?
12 |
13 | great question. please refer to the [example site](https://bimbo.nekoweb.org) for a guide
14 |
15 | # disclaimer
16 |
17 | this thing probably sucks in a bunch of ways. it's probably less performant or whatever than other things you could use. i do not care. i made this to cater to my needs and if it resonates with other ppl, that's just a nice bonus.
18 |
19 | i do intend to add more documentation at some point, but a Project Goal is to keep that stuff very light. i want it to be reasonable for someone to pick up Bimbo quickly and easily retain most of that knowledge so you don't need to watch a video tutorial if you haven't updated your site in a year
20 |
21 | # contributing
22 |
23 | if you're smart and think you can improve Bimbo by adding a cool new feature or streamlining workflows within it, i'm open to pull requests. just make sure you're prepared to educate me on what you did so i can continue understanding how this all fits together
24 |
25 | # credits
26 |
27 | Bimbo's default look and feel was directly ripped from [Marina Ayano Kittaka](https://bsky.app/profile/even-kei.bsky.social)'s [Zonelets](https://zonelets.net/). you could likely drop in any Zonelets themes with Bimbo and they should Just Work.
28 |
29 | shoutouts also to [Kate Bagenzo](https://katebagenzo.neocities.org/)'s [Strawberry Starter](https://strawberrystarter.neocities.org/) which might be considered a different approach to the same problem that Bimbo is trying to solve (but probably a smarter one, since it uses an established SSG as a base)
--------------------------------------------------------------------------------
/example-project/content/posts/5-data.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: data & config
3 | date: 2025-02-05
4 | draft: false
5 | ---
6 |
7 | in addition to injecting your content into the relevant templates, Bimbo also checks a handful of locations for metadata values. this metadata is compiled into a single "dictionary" object that can be referenced during the build process, allowing matching "keys" in your templates to be dynamically replaced with data you've supplied.
8 |
9 | let's look at some of the options you have for defining data:
10 |
11 | # bimbo.yaml
12 |
13 | `bimbo.yaml` acts as a "global" configuration file that all pages have access to. for example, you may notice the footer on this page includes the title of this blog and my name. if i wanted to include the title elsewhere, i could use this placeholder in a `.html` or `.hbs` file:
14 |
15 | `{{site.title}}`
16 |
17 | this placeholder matches a key in `bimbo.yaml`:
18 |
19 | ```
20 | site:
21 | title: Bimbo Blog
22 | ```
23 |
24 | so `{{site.title}}` will be replaced with "Bimbo Blog" when the site is rebuilt.
25 |
26 | # content "front matter"
27 |
28 | you can also include key/value pairs directly inside your content `.md` files - using this post as example:
29 |
30 | ```
31 | ---
32 | title: data & config
33 | date: 2025-02-05
34 | draft: false
35 | ---
36 | ```
37 |
38 | this is called "front matter"
39 |
40 | as you might guess, the `title` and `date` values are pulled into the `post.html` template used to generate this page.
41 |
42 | the `draft` value is a bit special - if Bimbo sees tha this value is `true`, it will skip it during the build process.
43 |
44 | # defaults and overrides
45 |
46 | if you'd like to apply some default metadata to your content files, you can do so globally by setting the `contentDefaults` in `bimbo.yaml`.
47 |
48 | if you'd like to only apply defaults to a subset of your content, you can create a `~default.yaml` file in a folder, which will affect only `.md` files in that directory.
49 |
50 | Bimbo will ultimately use whatever values are most specific (global > local to folder > front matter) when generating a page.
--------------------------------------------------------------------------------
/.github/workflows/bin-test.yml:
--------------------------------------------------------------------------------
1 | name: Test Standalone Binaries
2 | on:
3 | workflow_run:
4 | workflows: [Create Standalone Binaries]
5 | types:
6 | - completed
7 |
8 | jobs:
9 | test-windows:
10 | runs-on: windows-latest
11 |
12 | steps:
13 | - name: Download binaries
14 | uses: actions/download-artifact@master
15 | with:
16 | name: bin
17 | path: ./bin
18 |
19 | - name: Start Bimbo
20 | run: ./bin/bimbo-win
21 |
22 | - name: Check server status
23 | uses: cygnetdigital/wait_for_response@v2.0.0
24 | with:
25 | url: 'http://localhost:6969/'
26 | responseCode: '200,500'
27 | timeout: 2000
28 | interval: 500
29 |
30 | test-mac:
31 | runs-on: macos-latest
32 |
33 | steps:
34 | - name: Download binaries
35 | uses: actions/download-artifact@master
36 | with:
37 | name: bin
38 | path: ./bin
39 |
40 | - name: Start Bimbo
41 | run: ./bin/bimbo-mac
42 |
43 | - name: Check server status
44 | uses: cygnetdigital/wait_for_response@v2.0.0
45 | with:
46 | url: 'http://localhost:6969/'
47 | responseCode: '200,500'
48 | timeout: 2000
49 | interval: 500
50 |
51 | test-linux:
52 | runs-on: ubuntu-latest
53 |
54 | steps:
55 | - name: Download binaries
56 | uses: actions/download-artifact@master
57 | with:
58 | name: bin
59 | path: ./bin
60 |
61 | - name: Start Bimbo
62 | run: ./bin/bimbo-linux
63 |
64 | - name: Check server status
65 | uses: cygnetdigital/wait_for_response@v2.0.0
66 | with:
67 | url: 'http://localhost:6969/'
68 | responseCode: '200,500'
69 | timeout: 2000
70 | interval: 500
71 |
72 | # - name: deploy2nekoweb
73 | # uses: deploy2nekoweb/deploy2nekoweb@v4
74 | # with:
75 | # nekoweb-api-key: ${{ secrets.NEKOWEB_API_KEY }}
76 | # # nekoweb-cookie: ${{ secrets.NEKOWEB_COOKIE }}
77 | # nekoweb-folder: 'bimbo-test'
78 | # directory: './example-project/public'
--------------------------------------------------------------------------------
/example-project/content/posts/3-folders.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: folders & files
3 | date: 2025-02-03
4 | draft: false
5 | ---
6 |
7 | now that you've successfully initialized a new Bimbo project and learned how to preview it, we can start talking about the editing process.
8 |
9 | if you look inside the folder we created in the last post, you'll see that there are now some subfolders and files that Bimbo created:
10 |
11 | # content
12 |
13 | `/content` is where most ppl will spend the bulk of their time. anything you write in here will appear on your website for all the world to see
14 |
15 | Bimbo expects to find `.md` (Markdown) files in this folder, which will be converted into `.html` files on build.
16 |
17 | # templates
18 |
19 | `/templates` is the next layer up in a sense - since you can't do very advanced formatting with Markdown, you'll likely want a bit of raw HTML in the mix.
20 |
21 | any `.html` files in this folder can be used as a base, with your content and other unique data being piped in at build time - no need to copy/paste stuff!
22 |
23 | `/templates/partials` contains `.hbs` (Handlebars) files - we'll go into more detail about these later, but they're basically smaller templates that can be nested within the larger ones.
24 |
25 | a simple example is the navigation bar at the top of this page - we want it everywhere, so it's included as a "partial" on each page template.
26 |
27 | # static
28 |
29 | `/static` is where you keep things that you want copied over 1:1 when the site is built.
30 |
31 | a good example of this might be some image files or CSS/JavaScript that doesn't require any processing through Bimbo
32 |
33 | # public
34 |
35 | finally, `/public` is where Bimbo will output everything. this is the fully generated site that will be uploaded to your webhost!
36 |
37 | you could totally do any editing of these files in Notepad or something simple like that, but i recommend downloading [Visual Studio Code](https://code.visualstudio.com/) for a nicer experience. just open the whole folder in VS Code and you'll be able to navigate between files quickly[^1]
38 |
39 | [^1]: i make frequent use of the "Find in Files" shortcut (Cmd/Ctrl+Shift+F), which can be super helpful when you're trying to understand how everything fits together
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "bimbo",
3 | "version": "1.3.0",
4 | "description": "static site generator for dumb girls",
5 | "main": "main.js",
6 | "bin": {
7 | "bimbo": "main.js"
8 | },
9 | "author": "izzy kestrel",
10 | "license": "ISC",
11 | "type": "module",
12 | "dependencies": {
13 | "@atproto/api": "^0.14.20",
14 | "@mdit/plugin-attrs": "^0.16.7",
15 | "alive-server": "^1.3.0",
16 | "cheerio": "^1.0.0",
17 | "dotenv": "^16.4.7",
18 | "electron-forge": "^5.2.4",
19 | "electron-log": "^5.4.3",
20 | "electron-squirrel-startup": "^1.0.1",
21 | "extract-zip": "^2.0.1",
22 | "feather-icons": "^4.29.2",
23 | "feed": "^4.2.2",
24 | "front-matter": "^4.0.2",
25 | "handlebars": "^4.7.8",
26 | "highlight.js": "^11.11.1",
27 | "http-proxy": "^1.18.1",
28 | "markdown-it": "^14.1.0",
29 | "markdown-it-footnote": "^4.0.0",
30 | "markdown-it-highlightjs": "^4.2.0",
31 | "moment": "^2.30.1",
32 | "underscore": "^1.13.7",
33 | "yaml": "^2.7.0"
34 | },
35 | "devDependencies": {
36 | "@electron-forge/cli": "^7.10.2",
37 | "@electron-forge/maker-deb": "^7.10.2",
38 | "@electron-forge/maker-rpm": "^7.10.2",
39 | "@electron-forge/maker-squirrel": "^7.10.2",
40 | "@electron-forge/maker-zip": "^7.10.2",
41 | "@electron-forge/plugin-auto-unpack-natives": "^7.10.2",
42 | "@electron-forge/plugin-fuses": "^7.10.2",
43 | "@electron/fuses": "^1.8.0",
44 | "electron": "^36.9.5"
45 | },
46 | "peerDependencies": {
47 | "typescript": "^5.0.0"
48 | },
49 | "scripts": {
50 | "start": "electron-forge start",
51 | "package": "electron-forge package",
52 | "make": "electron-forge make"
53 | },
54 | "config": {
55 | "forge": {
56 | "packagerConfig": {},
57 | "makers": [
58 | {
59 | "name": "@electron-forge/maker-squirrel",
60 | "config": {
61 | "name": "electron_quick_start"
62 | }
63 | },
64 | {
65 | "name": "@electron-forge/maker-zip",
66 | "platforms": [
67 | "darwin"
68 | ]
69 | },
70 | {
71 | "name": "@electron-forge/maker-deb",
72 | "config": {}
73 | },
74 | {
75 | "name": "@electron-forge/maker-rpm",
76 | "config": {}
77 | }
78 | ]
79 | }
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/example-project/content/posts/1-intro.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: intro to bimbo
3 | date: 2025-02-01
4 | draft: false
5 | ---
6 |
7 | [](https://bsky.app/profile/iznaut.com/post/3lgqk46ddes2w)
8 |
9 | when i was in the 5th grade i got into making my own websites. mostly they were just simple pages where i proudly displayed my collection of Dragonball Z gifs i had downloaded, but that's not the point
10 |
11 | the point is, if a 10 year old could make a website in the 90s, it should be even easier for an adult to make one in the 2020s…right?
12 |
13 | i mean, yeah? kinda. not as easy as i would like tho. there's no shortage of paths you could take to create your own personal website, but i've recently had a difficult time choosing a path for _myself_, much less someone who hasn't been doing stuff like this since they were a child
14 |
15 | so i made a _new_ path.
16 |
17 | a path for Bimbos, _by_ Bimbos.
18 |
19 | # static site generators
20 |
21 | Bimbo is a "static site generator" - a tool that can help you build a website without having to do a lot of busy work (e.g. writing bespoke HTML for every page). there is no shortage of tools like this (and honestly, most of them are probably better than mine[^1]), but they tend to assume you already know a lot about web development and have time to read a ton of documentation to use them
22 |
23 | i somehow find myself in a weird space where i don't have enough time to learn any of these existing tools, but _did_ have time to make my own. go figure.
24 |
25 | the goal of Bimbo is to be simple enough that you don't have to hold very much knowledge in your head to use it. you'll edit some files, let Bimbo do its thing, and in seconds you'll have a complete website ready to be uploaded to a hosting service.
26 |
27 | # files?
28 |
29 | yes, _files_. you will need to edit some text files for this.
30 |
31 | Bimbo is not a visual editor but it _does_ come with its own web server that reloads automatically when you make changes.
32 |
33 | i promise it's not _that_ bad.
34 |
35 | 
36 |
37 | [^1]: i've personally used [Eleventy (11ty)](https://www.11ty.dev/) before and it seems nice. if you wanted something more mature/advanced, that's probably what i would recommend (along with the [Strawberry Starter](https://strawberrystarter.neocities.org/) template)
38 |
39 | [^2]: if you want to skip all this coding crap and just Make Something quickly, i recommend [mmm.page](https://mmm.page/). i've used this to maintain my "portfolio site" for a number of years and i like it quite a lot, but there are accessibility concerns and it's not especially suited for a blog format
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 | .pnpm-debug.log*
9 |
10 | # Diagnostic reports (https://nodejs.org/api/report.html)
11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
12 |
13 | # Runtime data
14 | pids
15 | *.pid
16 | *.seed
17 | *.pid.lock
18 |
19 | # Directory for instrumented libs generated by jscoverage/JSCover
20 | lib-cov
21 |
22 | # Coverage directory used by tools like istanbul
23 | coverage
24 | *.lcov
25 |
26 | # nyc test coverage
27 | .nyc_output
28 |
29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
30 | .grunt
31 |
32 | # Bower dependency directory (https://bower.io/)
33 | bower_components
34 |
35 | # node-waf configuration
36 | .lock-wscript
37 |
38 | # Compiled binary addons (https://nodejs.org/api/addons.html)
39 | build/Release
40 |
41 | # Dependency directories
42 | node_modules/
43 | jspm_packages/
44 |
45 | # Snowpack dependency directory (https://snowpack.dev/)
46 | web_modules/
47 |
48 | # TypeScript cache
49 | *.tsbuildinfo
50 |
51 | # Optional npm cache directory
52 | .npm
53 |
54 | # Optional eslint cache
55 | .eslintcache
56 |
57 | # Optional stylelint cache
58 | .stylelintcache
59 |
60 | # Microbundle cache
61 | .rpt2_cache/
62 | .rts2_cache_cjs/
63 | .rts2_cache_es/
64 | .rts2_cache_umd/
65 |
66 | # Optional REPL history
67 | .node_repl_history
68 |
69 | # Output of 'npm pack'
70 | *.tgz
71 |
72 | # Yarn Integrity file
73 | .yarn-integrity
74 |
75 | # dotenv environment variable files
76 | .env
77 | .env.development.local
78 | .env.test.local
79 | .env.production.local
80 | .env.local
81 |
82 | # parcel-bundler cache (https://parceljs.org/)
83 | .cache
84 | .parcel-cache
85 |
86 | # Next.js build output
87 | .next
88 | out
89 |
90 | # Nuxt.js build / generate output
91 | .nuxt
92 | dist
93 |
94 | # Gatsby files
95 | .cache/
96 | # Comment in the public line in if your project uses Gatsby and not Next.js
97 | # https://nextjs.org/blog/next-9-1#public-directory-support
98 | # public
99 |
100 | # vuepress build output
101 | .vuepress/dist
102 |
103 | # vuepress v2.x temp and cache directory
104 | .temp
105 | .cache
106 |
107 | # Docusaurus cache and generated files
108 | .docusaurus
109 |
110 | # Serverless directories
111 | .serverless/
112 |
113 | # FuseBox cache
114 | .fusebox/
115 |
116 | # DynamoDB Local files
117 | .dynamodb/
118 |
119 | # TernJS port file
120 | .tern-port
121 |
122 | # Stores VSCode versions used for testing VSCode extensions
123 | .vscode-test
124 |
125 | # yarn v2
126 | .yarn/cache
127 | .yarn/unplugged
128 | .yarn/build-state.yml
129 | .yarn/install-state.gz
130 | .pnp.*
131 |
132 | public/
133 | bin/
134 | test/
135 |
136 | .DS_Store
--------------------------------------------------------------------------------
/bluesky.ts:
--------------------------------------------------------------------------------
1 | // source: https://kulpinski.dev/posts/embed-card-links-on-bluesky/
2 |
3 | import { AtpAgent, RichText } from "@atproto/api"
4 |
5 | type Metadata = {
6 | title: string
7 | description: string
8 | image: string
9 | }
10 |
11 | /**
12 | * Get the URL metadata
13 | * @param url - The URL to get the metadata for
14 | * @returns The metadata
15 | */
16 | const getUrlMetadata = async (url: string) => {
17 | const req = await fetch(`https://api.dub.co/metatags?url=${url}`)
18 | const metadata: Metadata = await req.json()
19 |
20 | return metadata
21 | }
22 |
23 | /**
24 | * Get the Bluesky embed card
25 | * @param url - The URL to get the embed card for
26 | * @param agent - The Bluesky agent
27 | * @returns The embed card
28 | */
29 | const getBlueskyEmbedCard = async (url: string | undefined, agent: AtpAgent) => {
30 | if (!url) return
31 |
32 | try {
33 | const metadata = await getUrlMetadata(url)
34 | const blob = await fetch(metadata.image).then(r => r.blob())
35 | const { data } = await agent.uploadBlob(blob, { encoding: "image/jpeg" })
36 |
37 | return {
38 | $type: "app.bsky.embed.external",
39 | external: {
40 | uri: url,
41 | title: metadata.title,
42 | description: metadata.description,
43 | thumb: data.blob,
44 | },
45 | }
46 | } catch (error) {
47 | console.error("Error fetching embed card:", error)
48 | return
49 | }
50 | }
51 |
52 | const createBlueskyEmbedCard = async (url: string, title: string, description: string, thumb: Blob, agent: AtpAgent) => {
53 | const { data } = await agent.uploadBlob(thumb, { encoding: "image/jpeg" })
54 |
55 | return {
56 | $type: "app.bsky.embed.external",
57 | external: {
58 | uri: url,
59 | title: title,
60 | description: description,
61 | thumb: data.blob,
62 | },
63 | }
64 | }
65 |
66 | /**
67 | * Get the Bluesky agent
68 | * @returns The Bluesky agent
69 | */
70 | const getBlueskyAgent = async () => {
71 | const agent = new AtpAgent({
72 | service: "https://bsky.social",
73 | })
74 |
75 | await agent.login({
76 | identifier: process.env.BLUESKY_USERNAME!,
77 | password: process.env.BLUESKY_PASSWORD!,
78 | })
79 |
80 | return agent
81 | }
82 |
83 | /**
84 | * Send a post to Bluesky
85 | * @param text - The text of the post
86 | * @param url - The URL to include in the post
87 | */
88 | export const sendBlueskyPost = async (text: string, url?: string) => {
89 | const agent = await getBlueskyAgent()
90 | const rt = new RichText({ text })
91 | await rt.detectFacets(agent)
92 |
93 | await agent.post({
94 | text: rt.text,
95 | facets: rt.facets,
96 | embed: await getBlueskyEmbedCard(url, agent),
97 | })
98 | }
99 |
100 | export const sendBlueskyPostWithEmbed = async (text: string, url: string, title: string, description: string, thumb: Blob) => {
101 | const agent = await getBlueskyAgent()
102 | const rt = new RichText({ text })
103 | await rt.detectFacets(agent)
104 |
105 | const postData = await agent.post({
106 | text: rt.text,
107 | facets: rt.facets,
108 | embed: await createBlueskyEmbedCard(url, title, description, thumb, agent),
109 | })
110 |
111 | const splitUri = postData.uri.split('/')
112 | const postId = splitUri[splitUri.length - 1]
113 |
114 | return {
115 | id: postId,
116 | handle: agent.sessionManager.session.handle
117 | }
118 | }
--------------------------------------------------------------------------------
/example-project/static/style/style.css:
--------------------------------------------------------------------------------
1 | /* CSS is how you can add style to your website, such as colors, fonts, and positioning of your
2 | HTML content. To learn how to do something, just try searching Google for questions like
3 | "how to change link color." */
4 |
5 | body {
6 | background-color: #436a7b;
7 | background-image: url('../images/bg_diamond.png');
8 | background-position: top;
9 | font-size: 18px;
10 | font-family: Georgia, "Times New Roman", serif;
11 | margin: 0;
12 | }
13 |
14 | p {
15 | line-height: 1.6em;
16 | /*I find the default HTML line-height tends to be a bit claustrophobic for main text*/
17 | }
18 |
19 | hr {
20 | border: solid #c7b591;
21 | border-width: 2px 0 0 0;
22 | }
23 |
24 | img {
25 | max-width: 100%;
26 | height: auto;
27 | margin-top: 0.5em;
28 | margin-bottom: 0.5em;
29 | }
30 |
31 | .right {
32 | float: right;
33 | margin-left: 1em;
34 | }
35 |
36 | .left {
37 | float: left;
38 | margin-right: 1em;
39 | }
40 |
41 | .center {
42 | display: block;
43 | margin-right: auto;
44 | margin-left: auto;
45 | text-align: center;
46 | }
47 |
48 | @media only screen and (min-width: 600px) {
49 | .small {
50 | max-width: 60%;
51 | height: auto;
52 | }
53 | }
54 |
55 | .caption {
56 | margin-top: 0;
57 | font-size: 0.9em;
58 | font-style: italic;
59 | }
60 |
61 | a:hover {
62 | background-color: #fff6e6;
63 | }
64 |
65 | h1,
66 | h2,
67 | h3,
68 | h4,
69 | h5 {
70 | font-family: Tahoma, Geneva, sans-serif;
71 | color: #34436f;
72 | }
73 |
74 | /*#CONTAINER is the rectangle that contains everything but the background!*/
75 | #container {
76 | margin: 3em auto;
77 | width: 90%;
78 | max-width: 700px;
79 | background-color: #f1e3c9;
80 | color: #151515;
81 | outline-color: #a9a9a9;
82 | outline-style: ridge;
83 | outline-width: 4px;
84 | outline-offset: 0;
85 | }
86 |
87 | #content {
88 | padding: 10px 5% 20px 5%;
89 | }
90 |
91 |
92 | /*HEADER STYLE*/
93 | #header {
94 | background-color: #384879;
95 | padding: 0 5%;
96 | border-color: #a9a9a9;
97 | border-style: ridge;
98 | border-width: 0 0 4px 0;
99 | }
100 |
101 | #header ul {
102 | list-style-type: none;
103 | padding: 0.5em 0;
104 | margin: 0;
105 | }
106 |
107 | #header li {
108 | font-size: 1.2em;
109 | display: inline-block;
110 | margin-right: 1.5em;
111 | margin-bottom: 0.2em;
112 | margin-top: 0.2em;
113 | }
114 |
115 | #header li a {
116 | color: white;
117 | text-decoration: none;
118 | background-color: inherit;
119 | }
120 |
121 | #header li a:hover {
122 | text-decoration: underline;
123 | }
124 |
125 | /*POST LIST STYLE*/
126 | #postlistdiv ul {
127 | font-size: 1.2em;
128 | padding: 0;
129 | list-style-type: none;
130 | }
131 |
132 | #recentpostlistdiv ul {
133 | font-size: 1.2em;
134 | padding: 0;
135 | list-style-type: none;
136 | }
137 |
138 | .moreposts {
139 | font-size: 0.8em;
140 | margin-top: 0.2em;
141 | }
142 |
143 | /*NEXT AND PREVIOUS LINKS STYLE*/
144 | #nextprev {
145 | text-align: center;
146 | margin-top: 1.4em;
147 | }
148 |
149 | /*DISQUS STYLE*/
150 | #disqus_thread {
151 | margin-top: 1.6em;
152 | }
153 |
154 | /*FOOTER STYLE*/
155 | #footer {
156 | font-size: 0.8em;
157 | padding: 0 5% 10px 5%;
158 | }
159 |
160 | /* code {
161 | padding: 0 0.5em 0 0.5em;
162 | background-color: #151515;
163 | color: white;
164 | font-family: monospace;
165 | }
166 |
167 | pre {
168 | padding: 0.5em;
169 | white-space: pre-wrap;
170 | background-color: black;
171 | } */
172 |
--------------------------------------------------------------------------------
/example-project/content/posts/6-beyond.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: beyond the blog
3 | date: 2025-02-06
4 | draft: false
5 | ---
6 |
7 | the default structure of a Bimbo project is designed to support blogging, as that's a pretty common use case. however, it's far from the only thing Bimbo is capable of!
8 |
9 | you could easily modify the existing templates to create new ones entirely to suit your needs. this will likely require a bit of HTML knowledge, which is out of scope for me to teach here.[^1] i do think it's worth touching briefly on [Handlebars](https://handlebarsjs.com/), tho!
10 |
11 | let's look at `/templates/archive.html`:
12 |
13 | ```
14 |
15 |
16 | {{> head}}
17 |
18 |
19 | 52 | ``` 53 | 54 | see the `data-bluesky-url` value? let's look at that more closely: 55 | 56 | ``` 57 | data-bluesky-uri="at://did:plc:sofkq7uzgyczeyl24wxuc47o/app.bsky.feed.post/3lincp4ikhe2c" 58 | ``` 59 | 60 | it's still ugly, but you can hopefully see now where these IDs are coming from: the user is just after the `at://did:plc:` bit and the post is after that final slash. these are the values you'll want to copy out and place in their respective locations[^6] 61 | 62 | # that's it? 63 | 64 | that's it! your post should now have some indication of being connected to Bluesky at the bottom. i should also note - if you don't include a `bskyPostId` on a post, it simply won't show any of this junk. so it's totally opt-in. 65 | 66 | ## bonus feature: icons 67 | 68 | this is not _really_ relevant to all this comments stuff but you may also notice that there are icons alongside your reply/repost/like counts - the latest Bimbo has a new Handlebars helper function that allows you to quickly add icons in your templates via [Feather](https://feathericons.com/) 69 | 70 | if you want to icons elsewhere on your site, i recommend checking out `templates/partials/bsky-comments.hbs` to see how i'm implementing them. have fun! 71 | 72 | [^1]: i mean, in theory. let's be optimistic about the things ppl might have to say on the internet, just for the sake of this post 73 | [^2]: i am not optimistic that you would want to hear the things ppl might have to say on Twitter at this point, tbh 74 | [^3]: tbf about 90% of this project is me just "hacking it in". i guess the real difference is that i'm writing a tutorial for how to use it? 75 | [^4]: via the comments at the bottom of this page, perhaps? 76 | [^5]: you don't have to make a new account just for this purpose, but it is kinda cool to have a user handle that points at your blog's domain 77 | [^6]: the user ID will be referenced globally from `bimbo.yaml`, so this should be the only time you have to set it. the post ID will likely be unique per blog post, though -------------------------------------------------------------------------------- /example-project/templates/partials/bsky-comments.hbs: -------------------------------------------------------------------------------- 1 | 16 | 18 | -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import * as fs from 'node:fs' 4 | import * as path from 'node:path' 5 | import * as yaml from 'yaml' 6 | import markdownit from 'markdown-it' 7 | import markdownItFootnote from 'markdown-it-footnote' 8 | import markdownItHighlightjs from 'markdown-it-highlightjs' 9 | import { attrs } from "@mdit/plugin-attrs" 10 | import fm from 'front-matter' 11 | import Handlebars from "handlebars" 12 | import moment from 'moment' 13 | import _ from 'underscore' 14 | import live from 'alive-server' 15 | import extract from 'extract-zip' 16 | import { Feed } from 'feed' 17 | import * as cheerio from 'cheerio' 18 | import * as feather from 'feather-icons' 19 | import { sendBlueskyPostWithEmbed } from './bluesky.ts' 20 | 21 | let mainWindow 22 | 23 | const paths = { 24 | "content": "content", 25 | "posts": "content/posts", 26 | "data": "data", 27 | "templates": "templates", 28 | "partials": "templates/partials", 29 | "static": "static", 30 | "build": "public" 31 | } 32 | 33 | const yamlFilename = 'bimbo.yaml' 34 | const exampleZipPath = './example.zip' 35 | 36 | const defaultYaml = { 37 | "site": { 38 | "title": "My Cool Website", 39 | "description": "my cool description", 40 | "authorName": "sexygurl69", 41 | "authorUrl": "https://bimbo.nekoweb.org/", 42 | "dateFormat": "YYYY-MM-DD", 43 | "sortPostsAscending": false, 44 | "codeTheme": "tokyo-night-dark" 45 | }, 46 | "contentDefaults": { 47 | "title": "cool untitled page", 48 | "template": "default.html", 49 | "draft": false 50 | } 51 | } 52 | 53 | let rssFeed 54 | 55 | const buildOnly = process.argv.includes('build') || process.argv.includes('deploy') 56 | 57 | let startPath = "" 58 | 59 | // // if running from binary, use exec path 60 | // if (startPath.includes('/bin')) { 61 | // startPath = process.cwd() 62 | // } 63 | 64 | const pathArgIndex = _.indexOf(process.argv, '--path') + 1 65 | 66 | // process.chdir(startPath) 67 | 68 | if (pathArgIndex) { 69 | process.chdir(process.argv[process.argv.length - 1]) 70 | } 71 | 72 | let watchData 73 | let pagesToUpdate = {} 74 | 75 | log(`current working directory: ${process.cwd()}`) 76 | 77 | // if (buildOnly) { 78 | // build() 79 | // } 80 | // else { 81 | // watch() 82 | // } 83 | 84 | 85 | import pkg from 'electron'; 86 | const { app, BrowserWindow, ipcMain, dialog, screen } = pkg; 87 | 88 | import { fileURLToPath } from 'url'; 89 | 90 | const __filename = fileURLToPath(import.meta.url); 91 | const __dirname = path.dirname(__filename); 92 | 93 | const createWindow = () => { 94 | let opts = { 95 | title: "bimbo", 96 | width: 320, 97 | height: 350, 98 | frame: false, 99 | alwaysOnTop: true, 100 | transparent: true, 101 | webPreferences: { 102 | preload: path.join(__dirname, 'preload.js') 103 | } 104 | } 105 | 106 | let display = screen.getPrimaryDisplay() 107 | let width = display.bounds.width 108 | let height = display.bounds.height 109 | 110 | opts.x = width 111 | opts.y = height 112 | 113 | mainWindow = new BrowserWindow(opts) 114 | 115 | mainWindow.loadFile('electron/index.html') 116 | 117 | mainWindow.webContents.openDevTools({ mode: 'detach' }) 118 | } 119 | 120 | app.whenReady().then(() => { 121 | createWindow() 122 | 123 | ipcMain.handle('dialog', async (event, method, params) => { 124 | let dirHandle = await dialog[method](params); 125 | let newPath = dirHandle.filePaths[0] 126 | startPath = newPath 127 | process.chdir(startPath) 128 | return fs.existsSync('bimbo.yaml') 129 | }); 130 | 131 | ipcMain.handle('start-watch', async () => { 132 | watch() 133 | return true 134 | }) 135 | 136 | ipcMain.handle('quit', async () => { 137 | app.quit() 138 | }) 139 | }) 140 | 141 | async function init() { 142 | if (fs.existsSync(exampleZipPath)) { 143 | try { 144 | await extract(exampleZipPath, { dir: process.cwd() }) 145 | } 146 | catch (err) { 147 | console.log(err) 148 | } 149 | 150 | fs.rmSync(exampleZipPath) 151 | } 152 | else { 153 | // create base files/folders 154 | _.forEach(paths, (dir) => { 155 | fs.mkdirSync(dir) 156 | }) 157 | 158 | fs.writeFileSync(yamlFilename, yaml.stringify(defaultYaml)) 159 | } 160 | } 161 | 162 | async function build() { 163 | if (!fs.existsSync(yamlFilename)) { 164 | log('failed to find bimbo.yml file, aborting...') 165 | return 166 | // await init() 167 | } 168 | 169 | // load site config data 170 | let data = yaml.parse( 171 | fs.readFileSync(yamlFilename, "utf-8") 172 | ) 173 | data.pages = [] 174 | 175 | // register Handlebars partials 176 | const partials = fs.readdirSync(paths.partials); 177 | 178 | partials.forEach(function (filename) { 179 | var matches = /^([^.]+).hbs$/.exec(filename); 180 | if (!matches) { 181 | return; 182 | } 183 | var name = matches[1]; 184 | var template = fs.readFileSync(path.join(paths.partials, filename), 'utf8'); 185 | Handlebars.registerPartial(name, template); 186 | }); 187 | 188 | // TODO make separate js for handlebars helpers 189 | Handlebars.registerHelper('formatDate', function (date) { 190 | return moment(date).utc().format(data.site.dateFormat) 191 | }) 192 | 193 | Handlebars.registerHelper('getIcon', function (name, options) { 194 | let icon = feather.icons[name] 195 | icon.attrs = { ...icon.attrs, ...options.hash } 196 | return icon.toSvg() 197 | }) 198 | 199 | Handlebars.registerHelper('useFirstValid', function () { 200 | const valid = _.filter(arguments, (arg) => { 201 | return _.isString(arg) 202 | }) 203 | 204 | return valid[0] 205 | }) 206 | 207 | if (fs.existsSync(paths.build)) { 208 | fs.rmSync(paths.build, { recursive: true, force: true }); 209 | } 210 | fs.mkdirSync(paths.build) 211 | 212 | rssFeed = new Feed({ 213 | title: data.site.title, 214 | description: data.site.description, 215 | id: data.site.authorUrl, 216 | link: data.site.url, 217 | author: { 218 | name: data.site.authorName, 219 | email: data.site.authorEmail, 220 | link: data.site.authorUrl 221 | } 222 | }) 223 | 224 | data.site.userDefined = {} 225 | 226 | const dataFilepaths = await fs.promises.readdir(paths.data, { recursive: true }) 227 | 228 | _.each(dataFilepaths, (filepath) => { 229 | const jsonData = fs.readFileSync(path.join(paths.data, filepath), "utf-8") 230 | const dataName = path.basename(filepath, '.json') 231 | 232 | data.site.userDefined[dataName] = JSON.parse(jsonData) 233 | }) 234 | 235 | const contentFilepaths = await fs.promises.readdir(paths.content, { recursive: true }) 236 | let mdPaths = contentFilepaths.filter((item) => { return path.extname(item) == '.md' }) 237 | 238 | mdPaths.forEach((item) => { 239 | data = updateMetadata(path.join(paths.content, item), data) 240 | }) 241 | 242 | if (_.size(pagesToUpdate)) { 243 | const postsData = await Promise.all( 244 | _.values(pagesToUpdate).map( 245 | postObj => sendBlueskyPostWithEmbed(...postObj) 246 | ) 247 | ) 248 | 249 | let index = 0 250 | 251 | _.each(pagesToUpdate, (postData, filepath) => { 252 | const pageIndex = _.findIndex(data.pages, (page) => { 253 | return page.path == filepath 254 | }) 255 | 256 | const page = data.pages[pageIndex] 257 | 258 | data.pages[pageIndex].bskyPostId = postsData[index].id 259 | 260 | fs.writeFileSync( 261 | page.path, 262 | page.md.replace('bskyPostId: tbd', `bskyPostId: ${postsData[index].id}`) 263 | ) 264 | 265 | log('Successfully posted to Bluesky!') 266 | log(`https://bsky.app/profile/${postsData[index].handle}/post/${postsData[index].id}`) 267 | 268 | index++ 269 | }) 270 | } 271 | 272 | data.site.navPages = _.chain(data.pages) 273 | .pick((v) => { return v.navIndex }) 274 | .sortBy((v) => { return v.navIndex }) 275 | .value() 276 | 277 | data.site.blogPosts = _.chain(data.pages) 278 | .filter((v) => { return path.dirname(v.path) == paths.posts }) 279 | .sortBy((v) => { return v.date * (data.site.sortPostsAscending ? 1 : -1) }) 280 | .value() 281 | 282 | // include prev/next context for posts 283 | _.each(data.site.blogPosts, (v, i) => { 284 | if (i - 1 > -1) { 285 | data.site.blogPosts[i].postNext = data.site.blogPosts[i - 1] 286 | } 287 | if (i + 1 < data.site.blogPosts.length) { 288 | data.site.blogPosts[i].postPrev = data.site.blogPosts[i + 1] 289 | } 290 | }) 291 | 292 | generatePages(data) 293 | 294 | // copy static pages 295 | fs.cp(paths.static, paths.build, { recursive: true }, (err) => { if (err) { console.log(err) } }) 296 | 297 | fs.writeFileSync( 298 | path.join(paths.build, 'feed.xml'), 299 | rssFeed.rss2() 300 | ); 301 | 302 | try { 303 | if (data.site.integrations.bskyUserId) { 304 | const wellKnownPath = path.join(paths.build, '.well-known') 305 | 306 | fs.mkdirSync(wellKnownPath) 307 | fs.writeFileSync( 308 | path.join(wellKnownPath, 'atproto-did'), 309 | `did:plc:${data.site.integrations.bskyUserId}` 310 | ) 311 | } 312 | } 313 | catch (err) { 314 | log('no Bluesky User ID set, skipping integrations...') 315 | console.log(err) 316 | } 317 | 318 | process.watchData = data 319 | 320 | log("💅 Bimbo build completed!") 321 | } 322 | 323 | function getContentDefaults(dir) { 324 | const defaultFilepath = path.join(dir, '~default.yaml') 325 | 326 | if (fs.existsSync(defaultFilepath)) { 327 | return yaml.parse( 328 | fs.readFileSync(defaultFilepath, "utf-8") 329 | ) 330 | } 331 | else { 332 | return {} 333 | } 334 | } 335 | 336 | function updateMetadata(filepath, data) { 337 | const originalMd = fs.readFileSync(filepath, "utf-8") 338 | 339 | let frontMatter = fm(originalMd) 340 | 341 | const md = markdownit({ 342 | html: true 343 | }) 344 | .use(markdownItFootnote) 345 | .use(markdownItHighlightjs) 346 | .use(attrs) 347 | 348 | frontMatter.attributes = { 349 | ...data.contentDefaults, // global defaults 350 | ...getContentDefaults(path.dirname(filepath)), // local defaults 351 | ...frontMatter.attributes 352 | } 353 | 354 | let page = { 355 | 'path': filepath, 356 | 'url': filepath.replace(paths.content, '').replace('.md', '.html'), 357 | 'content': md.render(frontMatter.body), 358 | 'md': originalMd 359 | } 360 | for (let key in frontMatter.attributes) { 361 | page[key] = frontMatter.attributes[key] 362 | } 363 | 364 | if (page.draft) { 365 | log(`skipping ${filepath} (draft)`) 366 | return data 367 | } 368 | 369 | // use filename as title if not defined 370 | if (!page.title) { 371 | page.title = path.basename(filepath, '.md') 372 | } 373 | 374 | if (page.redirect) { 375 | page.url = page.redirect 376 | } 377 | 378 | const $ = cheerio.load(page.content) 379 | 380 | if (!page.description) { 381 | // TODO make this smarter 382 | page.description = $('p').html() 383 | } 384 | 385 | let firstImgUrl = $('img').prop('src') 386 | 387 | if (!page.headerImage) { 388 | page.headerImage = firstImgUrl || data.site.headerImage 389 | } 390 | 391 | if (path.parse(page.headerImage).root == '/') { 392 | page.headerImage = new URL(page.headerImage, data.site.url).href 393 | } 394 | 395 | if (page.includeInRSS) { 396 | rssFeed.addItem({ 397 | title: page.title, 398 | description: page.description, 399 | link: page.url, 400 | date: page.date, 401 | content: page.content 402 | }) 403 | } 404 | 405 | if (page.bskyPostId == 'tbd' && process.argv.includes('--deploy')) { 406 | const headerImg = fs.readFileSync('static/images/header.png'); 407 | 408 | const bskyPost =[ 409 | `new post: ${page.title}`, 410 | new URL(page.url, data.site.url).href, 411 | page.title, 412 | page.description, 413 | new Blob([headerImg]), 414 | ] 415 | 416 | pagesToUpdate[filepath] = bskyPost 417 | } 418 | 419 | data.pages.push(page) 420 | 421 | return data 422 | } 423 | 424 | function generatePages(data) { 425 | _.each(data.pages, (page) => { 426 | if (page.redirect) { 427 | return 428 | } 429 | 430 | page.site = data.site 431 | 432 | let templatePath = path.join(paths.templates, page.template) 433 | 434 | // get html template 435 | if (!fs.existsSync(templatePath)) { 436 | console.warn("couldn't find template, using default") 437 | page.template = 'default.html' 438 | templatePath = path.join(paths.templates, 'default.html') 439 | } 440 | 441 | let htmlOutput = fs.readFileSync(templatePath, "utf-8") 442 | 443 | // compile html template 444 | let htmlTemplate = Handlebars.compile(htmlOutput) 445 | htmlOutput = htmlTemplate(page) 446 | 447 | let outputPath = page.url 448 | let outputDir = path.dirname(outputPath) 449 | 450 | if (!fs.existsSync(outputDir)) { 451 | fs.mkdirSync(path.join(paths.build, outputDir), { recursive: true }) 452 | } 453 | 454 | fs.writeFileSync( 455 | path.join(paths.build, outputPath), 456 | htmlOutput 457 | ); 458 | 459 | return outputPath 460 | }) 461 | } 462 | 463 | async function watch() { 464 | await build() 465 | 466 | live.start({ 467 | mount: [['/', paths.build]], 468 | watch: [paths.content, paths.static, paths.templates, yamlFilename], 469 | port: 6969, 470 | wait: 1000, 471 | }) 472 | 473 | live.watcher.on('change', async function (e) { 474 | log('rebuilding...') 475 | await build() 476 | }) 477 | } 478 | 479 | function log(msg) { 480 | console.log(`💖BIMBO💖 logger: ${msg}`) 481 | if (mainWindow) { 482 | 483 | mainWindow.webContents.send('bimbo-log', `💖BIMBO💖 logger: ${msg}`); 484 | } 485 | 486 | } 487 | 488 | // function upload() { 489 | // let formData = new FormData() 490 | // request 491 | // } --------------------------------------------------------------------------------this is a post to test comments on Bimbo blog!
— bimbo.nekoweb.org (@bimbo.nekoweb.org) February 20, 2025 at 4:12 PM