Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- run: npm ci --ignore-scripts
# build + the full test suite, identical to what runs locally
- run: npm run verify
82 changes: 82 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
name: Publish

# Releasing is not a thing anyone does. Merge a version bump to main and the
# package ships: this workflow asks the registry whether package.json's version
# already exists, and publishes it if not.
#
# Why that check rather than a tag trigger: a tag pushed by GITHUB_TOKEN does
# not start another workflow, so "push a tag, let publish.yml notice" silently
# never runs. Asking npm what is published is also idempotent — re-running this,
# or pushing a tag by hand, cannot double-publish or fail confusingly.
on:
push:
branches: [main]
tags: ['v*']
# The reconciler, and the reason this is reliable. A merge made by auto-merge
# uses GITHUB_TOKEN, and a push with that token starts no workflow — so the
# push trigger above silently does not fire for exactly the merges that matter.
# The schedule asks the registry the same idempotent question on a timer: is
# package.json's version published? If a release was missed by any means, it
# goes out within the hour without anyone noticing it was missed.
schedule:
- cron: '29 * * * *'
workflow_dispatch:

jobs:
publish:
runs-on: ubuntu-latest
permissions:
# Tagging the released commit, so a version on the registry can always be
# traced back to the tree it was built from.
contents: write
# npm provenance: proves on the registry that this tarball was built by
# this workflow from this commit.
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
# npm >= 11.5.1 is required for trusted publishing (OIDC); Node 24 ships it.
node-version: '24'
registry-url: 'https://registry.npmjs.org'

- name: Is this version already on the registry?
id: check
run: |
name=$(node -p "require('./package.json').name")
version=$(node -p "require('./package.json').version")
echo "version=$version" >> "$GITHUB_OUTPUT"
if npm view "$name@$version" version >/dev/null 2>&1; then
echo "→ $name@$version is already published; nothing to do."
echo "publish=false" >> "$GITHUB_OUTPUT"
else
echo "→ $name@$version is not on the registry; releasing it."
echo "publish=true" >> "$GITHUB_OUTPUT"
fi

- if: steps.check.outputs.publish == 'true'
run: npm ci --ignore-scripts

# Never publish something that would not have passed CI.
- if: steps.check.outputs.publish == 'true'
run: npm run verify

# Auth is trusted publishing (OIDC): npm accepts this job's identity token
# because the package's Trusted Publisher is pinned to exactly this repo
# and workflow. No npm token exists anywhere, so none can leak or expire.
- if: steps.check.outputs.publish == 'true'
run: npm publish

- name: Tag the released commit
if: steps.check.outputs.publish == 'true'
env:
TAG: v${{ steps.check.outputs.version }}
run: |
# Tag after a successful publish, so a tag never claims a release that
# did not happen. Skipped silently if it already exists.
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "→ tag $TAG already exists"
else
git tag "$TAG"
git push origin "$TAG"
fi
102 changes: 102 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# bip-kit — Building in Public

**Blog · Roadmap · Changelog** for product sites — one content contract instead of five blog stacks.

You want to build in public. What you don't want is a CMS, a markdown pipeline, three renderers, and a security review every time a product site needs a blog. bip-kit is the small, sharp core of that stack: it turns repo-authored markdown into **typed blocks** your components render, and gives you the **types** for a roadmap and a user-facing changelog. Bring your own design system.

```bash
npm i bip-kit
```

Zero dependencies. ESM, typed, ~200 lines you can read in one sitting.

## The idea

Building in public is a *content contract*, not a platform:

- **Blog** — long-form posts, written as markdown files in your repo, reviewed like code.
- **Roadmap** — a typed document (`RoadmapDoc`) your site renders, not a screenshot of a kanban board.
- **Changelog** — user-facing entries (`ChangelogEntry`), not a git log.

bip-kit owns the parsing and the types. Your app owns the routes, the rendering, and the look. That split is why the same kit serves sites with completely different design systems.

## Quick start

```ts
import { parseFrontmatter, parseContentBlocks } from "bip-kit";
import { readFileSync } from "node:fs";

const raw = readFileSync("content/blog/my-post.md", "utf8");
const { meta, body } = parseFrontmatter(raw);
const blocks = parseContentBlocks(body);

// blocks is a typed array — switch on block.type in your renderer:
// h2 · h3 · p · ul · ol · blockquote · code · table · image · embed
```

A minimal React renderer:

```tsx
function PostBody({ blocks }: { blocks: ContentBlock[] }) {
return blocks.map((b, i) => {
switch (b.type) {
case "h2": return <h2 key={i}>{b.text}</h2>;
case "p": return <p key={i}>{b.text}</p>;
case "ul": return <ul key={i}>{b.items.map((it) => <li key={it}>{it}</li>)}</ul>;
case "code": return <pre key={i}><code>{b.text}</code></pre>;
case "embed": return <VideoEmbed key={i} url={b.url} />;
// …handle the rest with your own components
}
});
}
```

Video embeds are **allowlisted, never arbitrary**: a lone YouTube/Vimeo URL on its own line becomes an `embed` block, and `parseVideoEmbed` refuses everything else — so a markdown file can never inject an iframe you didn't intend:

```ts
import { parseVideoEmbed, videoEmbedSrc } from "bip-kit";

const parsed = parseVideoEmbed(url); // { provider, id } | null
if (parsed) iframeSrc = videoEmbedSrc(parsed); // youtube-nocookie / player.vimeo
```

## What the parser understands

Ordinary markdown, deliberately scoped to what long-form product writing needs:

| Input | Block |
|-------|-------|
| `## …` / `### …` | `h2` / `h3` |
| Plain lines | `p` (consecutive lines join) |
| `- …` / `1. …` | `ul` / `ol` |
| `> …` | `blockquote` |
| ` ```lang ` fences (incl. `mermaid`) | `code` |
| GFM tables | `table` |
| `![alt](src)` | `image` |
| Lone YouTube/Vimeo URL | `embed` |

Trust boundary: **committed content only** — this parses your repo's markdown, not user input.

## Types you'll actually use

- `ContentBlock` — the discriminated union your renderer switches on
- `BlogPostMeta` — minimal post frontmatter (slug, title, summary, tags, …)
- `RoadmapDoc` / `RoadmapBucket` / `RoadmapItem` — a renderable roadmap
- `ChangelogEntry` / `ChangelogTag` — user-facing changelog entries
- `ReleaseEntry` — desktop/installer release notes

## Starter routes

[`templates/next-app`](./templates/next-app) has copy-paste Next.js route stubs for `/blog`, `/roadmap`, and `/changelog`. They're intentionally thin — copy them into your app and style with your own tokens. The kit never ships UI.

## Company vs users

This kit is for **product/company** building-in-public. User-generated blogs belong on a social layer — don't bolt a UGC CMS onto every product domain.

## Used in production

Extracted from, and dogfooded by, [FleetCrown](https://fleetcrown.com) (its Thoughts/blog, roadmap, and changelog) and AOZ Wohnen before it was ever a package.

## License

MIT
26 changes: 26 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Flat config (ESLint 9). Recommended presets only: this is ~200 lines of
// library code, and a bespoke rule set would be a second opinion to maintain
// for no benefit. The floor is "lint runs and can fail", not "lint encodes
// taste".
import js from '@eslint/js'
import globals from 'globals'
import tseslint from 'typescript-eslint'

export default tseslint.config(
{
// dist/ is generated by `tsc`; templates/ is copy-paste starter code that
// lives in the consumer's app, not in this package's build.
ignores: ['dist/**', 'node_modules/**', 'templates/**'],
},
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ['**/*.ts'],
languageOptions: { globals: globals.node },
},
{
// Tests are plain Node running under `node --test`.
files: ['test/**/*.js'],
languageOptions: { globals: { ...globals.node, ...globals.nodeBuiltin } },
},
)
Loading
Loading