├── 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 |

{{title}}

2 | 3 | {{{content}}} -------------------------------------------------------------------------------- /example-project/content/feed.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: RSS 3 | navIndex: 3 4 | redirect: /feed.xml 5 | --- -------------------------------------------------------------------------------- /example-project/static/images/header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iznaut/bimbo/HEAD/example-project/static/images/header.png -------------------------------------------------------------------------------- /example-project/content/posts/~default.yaml: -------------------------------------------------------------------------------- 1 | title: cool untitled post 2 | template: post.html 3 | includeInRSS: true 4 | draft: true -------------------------------------------------------------------------------- /example-project/static/images/dbz_goku.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iznaut/bimbo/HEAD/example-project/static/images/dbz_goku.gif -------------------------------------------------------------------------------- /example-project/static/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iznaut/bimbo/HEAD/example-project/static/images/favicon.ico -------------------------------------------------------------------------------- /example-project/static/images/bg_diamond.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iznaut/bimbo/HEAD/example-project/static/images/bg_diamond.png -------------------------------------------------------------------------------- /example-project/static/images/bsky_post.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iznaut/bimbo/HEAD/example-project/static/images/bsky_post.png -------------------------------------------------------------------------------- /example-project/content/archive.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: archive 3 | navIndex: 2 4 | template: archive.html 5 | --- 6 | 7 | read these posts to learn how to make your own website with Bimbo! -------------------------------------------------------------------------------- /example-project/templates/partials/navbar.hbs: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /example-project/templates/partials/post-list.hbs: -------------------------------------------------------------------------------- 1 |
2 | 7 |
-------------------------------------------------------------------------------- /example-project/templates/default.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{> head}} 4 | 5 |
6 | {{> navbar}} 7 |
8 | {{> content}} 9 |
10 | {{> footer}} 11 |
12 | 13 | -------------------------------------------------------------------------------- /.favorites.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "type": "File", 4 | "name": "/Users/ikestrel/Repos/bimbo/example-project/bimbo.yaml", 5 | "parent_id": null, 6 | "workspaceRoot": "/Users/ikestrel/Repos/bimbo", 7 | "workspacePath": "example-project/bimbo.yaml", 8 | "id": "uiyxJYXAajSU5Eks" 9 | } 10 | ] 11 | -------------------------------------------------------------------------------- /example-project/templates/archive.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{> head}} 4 | 5 | 6 |
7 | {{> navbar}} 8 |
9 |

{{title}}

10 | {{{content}}} 11 | {{> post-list}} 12 |
13 | {{> footer}} 14 |
15 | 16 | 17 | -------------------------------------------------------------------------------- /example-project/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{> head}} 4 | 5 |
6 | {{> navbar}} 7 |
8 | {{> content}} 9 |

recent posts:

10 | {{> post-list}} 11 |
12 | {{> footer}} 13 |
14 | 15 | -------------------------------------------------------------------------------- /example-project/templates/partials/footer.hbs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /preload.js: -------------------------------------------------------------------------------- 1 | const electron = require('electron') 2 | 3 | electron.contextBridge.exposeInMainWorld('electron', { 4 | openDialog: (method, config) => electron.ipcRenderer.invoke('dialog', method, config), 5 | startWatch: () => electron.ipcRenderer.invoke('start-watch'), 6 | log: (callback) => electron.ipcRenderer.on('bimbo-log', (e, ...args) => callback(args)), 7 | quit: () => electron.ipcRenderer.invoke('quit') 8 | }); -------------------------------------------------------------------------------- /example-project/content/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: home 3 | navIndex: 1 4 | template: index.html 5 | --- 6 | 7 | i wanted a blog. so i tried some Static Site Generator tools and they all seemed more complicated than i needed. i liked the vibe of [Zonelets](https://zonelets.net/), but missed some of the nice stuff you get with an SSG. what's a girl to do when she's too smart for the simple tool but too dumb for the advanced tools? 8 | 9 | she makes her own dumb tool. 10 | 11 | welcome to Bimbo. -------------------------------------------------------------------------------- /example-project/content/posts/4-building.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: going live 3 | date: 2025-02-04 4 | draft: false 5 | --- 6 | 7 | would love to implement some proper support for this at some point (e.g. run a command to upload your site) but for now it's a manual process. 8 | 9 | to put it simply: everything in your `/public` folder should be uploaded to your webhost! 10 | 11 | there are plenty of options for hosting, but it seems like [Neocities](https://neocities.org/) is the one i hear about most commonly. personally, i've been trying out [Nekoweb](https://nekoweb.org/), which is nice so far. -------------------------------------------------------------------------------- /.github/workflows/bin-build.yml: -------------------------------------------------------------------------------- 1 | name: Create Standalone Binaries 2 | on: 3 | workflow_dispatch: 4 | 5 | jobs: 6 | compile: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Checkout code 11 | uses: actions/checkout@v2 12 | 13 | - name: Install bun 14 | uses: oven-sh/setup-bun@v2 15 | 16 | - name: Install dependencies 17 | run: bun install 18 | 19 | - name: Run compile script 20 | run: ./compile.sh 21 | 22 | - name: Upload binaries 23 | uses: actions/upload-artifact@master 24 | with: 25 | name: bin 26 | path: ./bin 27 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": ["ESNext", "DOM"], 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | 22 | // Some stricter flags (disabled by default) 23 | "noUnusedLocals": false, 24 | "noUnusedParameters": false, 25 | "noPropertyAccessFromIndexSignature": false 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "name": "Launch Electron App", 5 | "program": "${workspaceFolder}/main.js", 6 | "request": "launch", 7 | "skipFiles": [ 8 | "/**" 9 | ], 10 | "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron", 11 | "type": "node" 12 | }, 13 | { 14 | "name": "Launch Program", 15 | "program": "${workspaceFolder}/main.js", 16 | "request": "launch", 17 | "args": ["--path", "./example-project"], 18 | "skipFiles": [ 19 | "/**" 20 | ], 21 | "type": "node" 22 | } 23 | ] 24 | } -------------------------------------------------------------------------------- /example-project/bimbo.yaml: -------------------------------------------------------------------------------- 1 | site: 2 | title: Bimbo Blog 3 | description: a static site generator for dumb girls 4 | url: https://bimbo.nekoweb.org/ 5 | headerImage: /images/header.png 6 | authorName: izzy kestrel 7 | authorUrl: https://iznaut.com/ 8 | authorEmail: contact@iznaut.com 9 | dateFormat: YYYY-MM-DD 10 | sortPostsAscending: true 11 | codeTheme: tokyo-night-dark 12 | integrations: 13 | bskyUserId: sofkq7uzgyczeyl24wxuc47o 14 | 15 | contentDefaults: # these get applied to any .md files with no `~default.yml` file in their folder 16 | title: cool untitled page # you can set all of these items per .md file. these are fallbacks 17 | template: default.html # be sure to include `{{{content}}}` in your template if you want stuff to appear 18 | draft: false # draft content is ignored on build and won't be published -------------------------------------------------------------------------------- /example-project/content/about.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: about 3 | navIndex: 3 4 | --- 5 | 6 | Bimbo is a "static site generator" - a tool that can help you build a website without having to do a lot of busy work. it was made by [izzy kestrel](https://iznaut.com/) for her personal use, but maybe it could be of value to others as well. 7 | 8 | the default look and feel of this blog 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. 9 | 10 | 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) -------------------------------------------------------------------------------- /.github/workflows/deploy-example.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy Website 2 | on: 3 | push: 4 | paths: 5 | - example-project/** 6 | 7 | permissions: 8 | contents: write 9 | 10 | env: 11 | BLUESKY_USERNAME: ${{ secrets.BLUESKY_USERNAME }} 12 | BLUESKY_PASSWORD: ${{ secrets.BLUESKY_PASSWORD }} 13 | 14 | jobs: 15 | build-and-deploy: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v3 20 | 21 | - name: Bimbo Build 22 | uses: iznaut/bimbo@main 23 | with: 24 | bimbo-path: './example-project' 25 | dev-mode: 'local' 26 | 27 | - name: Deploy 28 | uses: deploy2nekoweb/deploy2nekoweb@v4 29 | with: 30 | nekoweb-api-key: ${{ secrets.NEKOWEB_API_KEY }} 31 | # nekoweb-cookie: ${{ secrets.NEKOWEB_COOKIE }} 32 | nekoweb-folder: 'bimbo-example' 33 | directory: 'example-project/public' -------------------------------------------------------------------------------- /example-project/templates/partials/head.hbs: -------------------------------------------------------------------------------- 1 | 2 | {{title}} - {{site.title}} 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build/release 2 | 3 | on: push 4 | 5 | jobs: 6 | release: 7 | runs-on: ${{ matrix.os }} 8 | 9 | strategy: 10 | matrix: 11 | os: [macos-latest, ubuntu-latest, windows-latest] 12 | 13 | steps: 14 | - name: Check out Git repository 15 | uses: actions/checkout@v1 16 | 17 | - name: Install Node.js, NPM and Yarn 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: 24 21 | 22 | - name: Build/release Electron app 23 | uses: samuelmeuli/action-electron-builder@v1 24 | with: 25 | # GitHub token, automatically provided to the action 26 | # (No need to define this secret in the repo settings) 27 | github_token: ${{ secrets.github_token }} 28 | 29 | # If the commit is tagged with a version (e.g. "v1.0.0"), 30 | # release the app after building 31 | release: ${{ startsWith(github.ref, 'refs/tags/v') }} 32 | env: 33 | NODE_OPTIONS: '--max_old_space_size=4096' -------------------------------------------------------------------------------- /compile.sh: -------------------------------------------------------------------------------- 1 | #!/bin/zsh 2 | 3 | TEST_TARGET_DIR="./test" 4 | 5 | MAIN_JS="./main.js" 6 | BIN_DIR="./bin" 7 | EXAMPLE_PROJECT_DIR="./example-project" 8 | OUTFILE_PREFIX="bimbo" 9 | 10 | declare -A targets 11 | targets[win]="bun-windows-x64" 12 | targets[mac]="bun-darwin-arm64" 13 | targets[mac-intel]="bun-darwin-x64" 14 | targets[linux]="bun-linux-x64" 15 | 16 | for key value in ${(kv)targets}; do 17 | bun build $MAIN_JS --compile --minify --sourcemap --bytecode --target=${value} --outfile $BIN_DIR/$OUTFILE_PREFIX-${key} 18 | done 19 | 20 | # build only for current OS 21 | # bun build $MAIN_JS --compile --minify --sourcemap --bytecode --target=${targets[$OSTYPE]} --outfile $BIN_DIR/$OUTFILE_PREFIX-$OSTYPE 22 | 23 | # create example project zip 24 | pushd $EXAMPLE_PROJECT_DIR 25 | rm -rf ./public 26 | rm ../$BIN_DIR/example.zip 27 | zip -r ../$BIN_DIR/example.zip * 28 | popd 29 | 30 | # uncomment for testing 31 | # cd $BIN_DIR 32 | # mkdir $TEST_TARGET_DIR 33 | # rm -rf $TEST_TARGET_DIR/* 34 | # cp ./example.zip $TEST_TARGET_DIR/example.zip 35 | # ./bimbo-$OSTYPE --path $TEST_TARGET_DIR -------------------------------------------------------------------------------- /example-project/templates/post.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{> head}} 4 | 5 |
6 | {{> navbar}} 7 |
8 |

{{title}}

9 | {{#if date}} 10 |

{{formatDate date}}

11 | {{/if}} 12 | 13 | {{{content}}} 14 |
15 | {{#if comments}} 16 | {{> bsky-comments}} 17 | {{/if}} 18 |
19 | {{#if site.sortPostsAscending}} 20 | {{#if postNext}}« previous post | {{/if}} 21 | home 22 | {{#if postPrev}} | next post »{{/if}} 23 | {{else}} 24 | {{#if postNext}}« next post | {{/if}} 25 | home 26 | {{#if postPrev}} | previous post »{{/if}} 27 | {{/if}} 28 |
29 | {{> footer}} 30 |
31 | 32 | -------------------------------------------------------------------------------- /electron/index.html: -------------------------------------------------------------------------------- 1 | 7 | 8 |
9 | 10 | 11 |
12 | 13 | 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 | ![example bimbo blog](example.png) 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 | [![screenshot of the post that started it all](/images/bsky_post.png)](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 | ![classic animated gif of Goku from Dragonball Z](/images/dbz_goku.gif) 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 |
20 | {{> navbar}} 21 |
22 |

{{title}}

23 | {{{content}}} 24 | {{> post-list}} 25 |
26 | {{> footer}} 27 |
28 | 29 | 30 | 31 | ``` 32 | 33 | these double bracket placeholders that appear all over the template files are Handlebars expressions. if you read the previous post, you should be somewhat familiar with how they work. a simple example here is the `{{title}}` expression, which gets replaced on build with the `title` value defined in `archive.md`. 34 | 35 | by default, Handlebars will "escape" values returned by an expression. if you don't want this, you can add a third set of brackets to return the "raw" value instead (as is the case with the `{{{content}}}` expression, which is the Markdown body of `archive.md` converted into HTML) 36 | 37 | the last thing i'd like to point out in this template is the use of `>` - this is where the `.hbs` files in `/templates/partials` come in. instead of piping data in, these expressions will be expanded into HTML "partials". let's look more closely at `/templates/partials/post-list.hbs`, which will replace `{{> post-list}}`: 38 | 39 | ``` 40 |
41 | 46 |
47 | ``` 48 | 49 | here we have some more unique Handlebars syntax. let's talk about `#each` first, which accepts an array (in this case, `site.blogPosts`) as an parameter. the HTML inside the `#each` block will be rendered once per item in this array, which we can access using the `this` variable. 50 | 51 | the end result is a bulleted list of dates and titles, which link to the actual posts themselves. there's a bit of Bimbo specific[^2] junk[^3] happening here that isn't super important, but hopefully it mostly makes sense! 52 | 53 | i'd recommend checking out the [Handlebars documentation](https://handlebarsjs.com/guide/#simple-expressions) to learn more about this syntax and what kinds of things you can do with it 54 | 55 | # that's all! 56 | 57 | congrats! you made it to the end of the Bimbo tutorials. i hope it wasn't too complicated, but if it was, please feel free to [shoot me an email](mailto:bimbo@iznaut.com) or DM me on Bluesky and give me feedback! 58 | 59 | if you're the technical sort, you can also submit pull requests to the Bimbo repo on GitHub. 60 | 61 | have fun making a website! 62 | 63 | [^1]: [W3Schools](https://www.w3schools.com/html/) has been my go-to for years, but i'm open to suggestions for better resources to point folks toward! 64 | [^2]: `site.blogPosts` is kind of a "magic" variable in that it's not something you define anywhere - it's created dynamically during the build process. i don't love this and would like to find a better way of handling things like this that isn't so opaque or at least document it better 65 | [^3]: `formatDate` is a custom function inside Bimbo that does exactly what it says. you can adjust the actual formatting by editing the `site.dateFormat` value in `bimbo.yaml` -------------------------------------------------------------------------------- /example-project/content/posts/7-bsky-comments.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: comments with Bluesky 3 | date: 2025-02-07 4 | draft: false 5 | comments: true 6 | bskyPostId: 3lmuxetostg2h 7 | --- 8 | 9 | when you have a blog, it's nice for your readers to have a space where they can share their thoughts[^1] and connect with you. there are various options available for allowing this ([Disqus](https://disqus.com/) being the one i hear about the most), but we like to keep it sleezy here at Bimbo so i'm taking a different tact 10 | 11 | # Bluesky 12 | 13 | if you found this place, you _probably_ know what Bluesky is bc i have been posting about Bimbo on there. if you don't, it's a social media site like Twitter[^2] but with some futuristic tech stuff going on in the background that i don't really care to explain, BUT the important part for our purposes is that you can pretty easily use their API[^3] to grab data and do cool stuff with it on your website 14 | 15 | thanks to [this post from Jonathan Moallem of Caps Collective](https://capscollective.com/blog/bluesky-blog-comments/), it was trivially easy to just kind of drop-in support for "comments" via Bluesky posts. the idea is that you can make a post on Bluesky linking to your blog post and any replies to that will be displayed on the actual webpage of your blog post. 16 | 17 | the advantage of this approach is that it captures a lot of the conversation folks are already having in response to your Bluesky post (rather than splintering it with a bespoke commenting platform), it allows you to leverage Bluesky's moderation tools (blocking users on Bluesky will also hide their posts on your blog), and maybe most importantly (to me, anyway) - it doesn't require someone to sign up for another service (assuming they're already on Bluesky, obviously) 18 | 19 | # adding comments with Bimbo 20 | 21 | since this is something i'm personally interested in leveraging for my blog, i decided to make it a Proper Feature in Bimbo rather than just hacking it in[^3]. i think it's about as straightforward as it can be (without a lot of extra work on my end, anyway), but as always, please let me know[^4] if you have suggestions on how to improve this functionality 22 | 23 | also - i suppose it's worth saying that i assume you're using the latest version of Bimbo and a fresh project (initialized from the latest `example.zip`) for this to work. 24 | 25 | first off, you'll want to open `bimbo.yaml` and edit the value under `site.integrations.bskyUserId`: 26 | 27 | ``` 28 | ... 29 | sortPostsAscending: true 30 | codeTheme: tokyo-night-dark 31 | integrations: 32 | bskyUserId: sofkq7uzgyczeyl24wxuc47o 33 | ``` 34 | 35 | the existing user ID is for an account i made just for this demo site, which is [@bimbo.nekoweb.org](https://bsky.app/profile/bimbo.nekoweb.org)[^5]. the other important bit of config will be on the blog post itself, as we can see here on `7-bsky-comments.md`: 36 | 37 | ``` 38 | --- 39 | title: comments with Bluesky 40 | date: 2025-02-07 41 | draft: false 42 | bskyPostId: 3lincp4ikhe2c 43 | --- 44 | ``` 45 | 46 | so now you know my Bluesky user ID and the ID of the specific post that we want to display comments from. but how do you get these for _your_ blog? 47 | 48 | it's not pretty, but you'll want to open the elipsis (...) menu on the Bluesky post in question and click "Embed post". here's what i get when i copy this code: 49 | 50 | ``` 51 |

this is a post to test comments on Bimbo blog!

— bimbo.nekoweb.org (@bimbo.nekoweb.org) February 20, 2025 at 4:12 PM
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 |
17 |
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 | // } --------------------------------------------------------------------------------