@highjumpdigitalsoftware/blog-kit
v0.8.1
Published
Canonical, framework-agnostic blog component kit. One shared core (CSS tokens + .bk-* styles + manifest + behaviours) consumed by thin React (Next.js) and Astro adapters. The blog-publisher MCP reads core/manifest as its single source of truth, and sites
Downloads
163
Readme
blog-kit — shared, multi-framework blog component kit
One canonical component library that every blog (Next.js or Astro) is built from. A site supplies only its content (the MDX in its content repo) and its paint (theme.json); everything structural — component markup, CSS, behaviours, and the component manifest — lives here and is shared.
Canonical copy lives at
/home/dan/blog-kit(its own standalone project — destined for its ownhighjumpdigital/blog-kitrepo). It is vendored into each consumer at build time (a per-sitesync-blog-kit.mjscopiescore/+ the right adapter into the site). Edit the canonical copy; never hand-edit a vendored copy. Pin each site to a kit version.
Two ways to consume the kit
The kit serves both an in-house vendored path and an external installable-package path from one source of truth.
Vendored (our own sites).
sync-blog-kit.mjscopiescore/+ the right adapter (react/orastro/) into the site'ssrc/_blogkit/, committed to the site repo. This is unchanged — see INTEGRATION.md. The blog-publisher MCP readscore/manifest/as its single source of truth. On this path the site owns the MDX render options, so pass the kit's:options={ARTICLE_MDX_OPTIONS}(exported from@/_blogkit/react). It carriesblockJS: false, without whichnext-mdx-remotestrips every JSX-expression attribute and ~17 components silently render as nothing.Installable package
@highjumpdigitalsoftware/blog-kit(external clients). The samecore/+react/+astro/ship as a published npm package with three extra abilities the vendored path doesn't need. The managed path is dual-framework: a first-class Next.js path AND a first-class Astro path — same content API, same swappable source, same eject.- A swappable content-source adapter —
local(read.mdxfrom the repo) orhjd-api(fetch MDX from our per-tenant content API over Bearer auth). The two satisfy the sameContentSourceinterface (framework-neutral), so eject is a one-linesource.modeflip on either framework. - Route surfaces + a connect/init CLI —
<Article>(/blog/[slug]) and<BlogIndex>(/blog), source-agnostic, on both frameworks.npx @highjumpdigitalsoftware/blog-kit connect <pairing-code>(orinit) scaffolds the routes + an optional revalidate route + writes config/env. The framework is taken from the exchange, else--framework next|astro, else sniffed from the project.- Next.js: App-Router ISR routes (
export const revalidate), MDX rendered vianext-mdx-remote/rsc. New posts appear with no redeploy via the ISR timer; instant via/api/blogkit/revalidate(revalidatePath). - Astro: SSR routes (
output: 'server',prerender:false). Astro has no ISR and its MDX is build-time-only, so the remote MDX string is compiled at request time with@mdx-js/mdx+ the kit's React component map → static HTML injected viaset:html(identical markup to the.astrocomponents, eject-safe). Freshness = SSR always-fresh + an in-process TTL cache (the ISR analogue), busted instantly by the scaffolded/api/blogkit/revalidateAstro endpoint. Works on a host like Replit with zero cooperation.
- Next.js: App-Router ISR routes (
- npm packaging —
npm run build(tsc -p tsconfig.package.json+scripts/pkg-copy-assets.mjs) emitsdist/;npm run selfcheckis a fast Node-only sanity check. The package source lives underpackage/(package/astro/= the Astro managed layer); the.astroroute surfaces ship raw underastro/blogkit/. The existingcore//react/components /astro/components /demo// manifest are untouched (the component maps were factored intoreact/components-map.ts, re-exported byreact/index.ts— same public surface — so the Astro render path can import the map without pulling Next).
# Next.js npm i @highjumpdigitalsoftware/blog-kit npm i next-mdx-remote remark-gfm rehype-slug rehype-autolink-headings rehype-pretty-code npx @highjumpdigitalsoftware/blog-kit connect <pairing-code> # scaffolds Next ISR routes + writes config/env # Astro npm i @highjumpdigitalsoftware/blog-kit npm i @mdx-js/mdx react react-dom remark-gfm rehype-slug rehype-autolink-headings npx astro add node # an SSR adapter; set output:"server" npx @highjumpdigitalsoftware/blog-kit connect <pairing-code> # scaffolds Astro SSR routes (framework from the exchange)// Next: app/blogkit.config.ts · Astro: src/blogkit.config.ts import { defineConfig } from "@highjumpdigitalsoftware/blog-kit"; // Next // import { defineConfig } from "@highjumpdigitalsoftware/blog-kit/astro"; // Astro export default defineConfig({ source: { mode: "hjd-api" }, // URL+token from BLOGKIT_CONTENT_URL / BLOGKIT_SITE_TOKEN brand: { name: "Acme Co" }, // or { mode: "local", postsDir: "content/posts" } (ejected) });See the design doc
hjd-app/docs/managed-blog-plan.md(§7 adapter, §8 connection/API/freshness, §9 eject). The Astro path's request-time MDX compile is the key difference from Next's ISR — both meet "new posts appear with no redeploy".- A swappable content-source adapter —
The article hero (managed path)
<Article> renders <ArticleHero> above the body: the post's featuredImage, category,
reading time, title, description, author and date — one designed header instead of a bare <h1>.
It owns the page's <h1>. A host route must not print the title as well; the scaffolded route
no longer does.
Three shapes, one markup (core/css/article-hero.css, all --blog-* tokens, so a tenant theme
recolours it for free):
| shape | when |
|---|---|
| overlay (default) | post has a featured image — image band, scrim, copy over it |
| stacked | article.heroStyle: "stacked" — image on top, copy below on paper |
| plain | post has no featured image — copy on a tinted panel (automatic; heroStyle is ignored) |
// blogkit.config.ts
export default defineConfig({
source: { mode: "hjd-api" },
brand: { name: "Acme Co" },
article: { heroStyle: "stacked", showBackLink: false }, // all optional
});Two escape hatches, both above the config: pass hero to <Article> (Next) or fill its hero
slot (Astro) to draw your own frame — yours then owns the <h1> — or set
article.showHero: false for no hero at all.
Why it lives here: every managed post already carries featuredImage, and the kit already drew the
listing card from it. Leaving the article header to each site meant a site that took the scaffold
as-is shipped its posts with no header at all (0.6.x and earlier). npm run validate-hero proves
the render, the three shapes, and React/Astro markup parity.
Upgrading an existing install to 0.7
The hero arrives with the package, but the route file in the site still has the old bare
<h1> above <Article> — which would now render the title twice. Bump the kit, then re-stamp
the routes from this version's templates:
npm i @highjumpdigitalsoftware/blog-kit@latest
npx @highjumpdigitalsoftware/blog-kit init --force # rewrites the scaffolded routes; review the diffThe same command also adds the category + pagination routes (see below) if the install predates
them. Check the output: it must say OVERWROTE … (and wrote … for the new routes). If it only
says skip (exists), the package did not actually upgrade and you are running a pre-0.7 CLI, which
had no --force — confirm with npm ls @highjumpdigitalsoftware/blog-kit. (Pre-0.7 CLIs ignore an
unknown flag silently; 0.7.1+ rejects it and names its own version.) --force never touches blogkit.config.ts — your brand, paths and knobs survive — and leaves
any file that already matches the template alone. A site that hand-edited its route should instead
delete its own <h1> (and can copy the OG image block from
templates/blogkit/app/blog/[slug]/page.tsx.tmpl).
The listing routes (managed path)
blog-kit init scaffolds the whole listing surface, not just /blog:
| route | what it is |
|---|---|
| /blog | page 1 — featured hero + card grid + category nav |
| /blog/[slug] | the article (hero + body + tail) |
| /blog/page/[page] | listing pages 2+ |
| /blog/category/[category] | a category landing page |
| /blog/category/[category]/page/[page] | category pages 2+ |
<BlogIndex> renders category pills and a pager, so the last three are not
optional: without them every pill and every "Older →" link is a 404, which
is what two live client sites shipped with before 0.7.
Each route meets templates/blog-index/STANDARD.md: a self-referencing
canonical (never back to page 1), a unique <title>, /page/1 → the listing
base, and 404 for an unknown category or an out-of-range page. Canonicals are
absolute once you set brand.siteUrl in blogkit.config.ts, relative until
then — the kit never invents an origin.
The routes are scaffolded under /blog. If listingBasePath is something
else (Knightsbridge uses /news), move the scaffolded folder to match — the
config drives every link the kit renders.
npm run validate-listing proves the selection logic, npm run validate-scaffold
proves what the CLI writes, and scripts/validate-blog-index.mjs --root <site>
[--base <url>] proves a real install (it now understands the managed layout as
well as the vendored one).
The shape
core/ framework-AGNOSTIC — the real "build once"
css/
tokens.css --blog-* default theme, declared on .blog-root
base.css scoped reset + prose typography (isolation layer)
components.css every component's .bk-* BEM styling
index.css entrypoint: @imports the three above (in order)
icons.ts shared inline SVG strings
lib.ts shared prop logic (tone resolution, cx, …)
behaviors/ shared vanilla JS for interactive bits (data-bk-* hooks)
manifest/
components.json SINGLE source of truth — the MCP reads this
templates.json
frontmatter.json
react/ thin .tsx wrappers + blogComponents map (Next.js)
astro/ thin .astro wrappers (Astro)
demo/index.html static preview of components across themesA component = look + wrapper. The look (HTML structure + CSS) is framework-agnostic and lives in core/. The wrapper is a ~10-line shim per framework that emits the agreed markup. Restyle = edit core/css once → every framework + every site updates. Add a component = CSS once + a tiny wrapper in react/ and astro/ + a manifest entry.
The build contract (every component MUST follow)
- Styling lives only in
core/css/components.cssas global.bk-<name>BEM classes. No Astro<style>blocks, no Tailwind, no inlinestyle=with literal colours. This is what lets React and Astro render byte-identically. - Every value keys off a
--blog-*token (seetokens.css). Never hardcode a brand colour, font, radius, or shadow. A tenant re-skins by overriding tokens only. - Everything renders under
.blog-root. The base layer re-asserts inherited properties there, so the host page's CSS can't bleed in and the blog can't leak out. The only sanctioned inbound channel is the--blog-*token layer. - Shared logic goes in
core/lib.ts; shared markup (icons) incore/icons.ts; shared interactivity incore/behaviors/*.js(attached viadata-bk-*attributes). Wrappers stay thin and identical across frameworks. - Both adapters emit the same tag, classes, and structure. The React wrapper uses
className+dangerouslySetInnerHTML; the Astro wrapper usesclass+set:html+<slot/>. Nothing else should differ. - Renamed components keep an alias. If a canonical name differs from what a live site already authored, add the old name to
aliasComponents(React) / the Astro barrel and to the manifest entry'saliases, so existing published posts never break. - Add a
manifest/components.jsonentry (description,props,example,role,recommendedFor,pack,frameworks,aliases). The MCP surfaces this verbatim to authoring agents. - If the component's content arrives as a non-string prop, declare it in
react/prop-guard.tsx. Anything takingitems/rows/posts/ ascorerenders nothing when that prop is absent — which is the right behaviour on a client's live page and the reason a whole class of integration bug used to be invisible (see Behaviours, andblockJS: falsein INTEGRATION.md). Listing it there turns "rendered nothing" into a console error naming the cause. List only the props that ARE the content: decorative extras likeAuthorCard.linksare deliberately excluded, because warning about those trains everyone to ignore the warning. - If the component needs a behaviour, it mounts its own activator —
<BehaviorMount />in the React wrapper, the hoisted<script>in the Astro twin. Never leave activation to the site.
CalloutBox is the reference implementation of rules 1–7; ProseList (guarded) and TableOfContents (self-activating) cover 8 and 9.
Packs
- core — universal components every site gets.
- capture / hjd — domain packs (review/SaaS vs SEO-agency) enabled per site via the MCP's
componentsallowlist. - per-site custom — code that lives in the site repo's overlay, merged into the render map; not in this kit.
Behaviours — automatic
The interactive components (TableOfContents, FAQList, ComparisonTable, CodeSnippet, KeywordTable, DeliveryComparison) render markup that framework-neutral vanilla JS in core/behaviors/*.js completes on the client. They activate themselves — there is no per-site wiring. Each component carries its own activator (react/BehaviorMount.tsx on the React side, a hoisted module <script> on the Astro side), so a component works wherever it is rendered: the kit's <Article>, a hand-rolled route, a one-off page.
This used to be a manual step and it was the kit's sharpest edge, because forgetting it failed silently. <TableOfContents/> is the extreme case — its <ul> is empty in markup and the behaviour is its content — so a route that never mounted the activator published a permanently empty "In this article" box. It looked correct in every preview (previews mount the activator), so it could sit live for weeks. Self-activation removes the step instead of documenting it harder.
<BlogBehaviors /> and BlogBehaviors.astro remain exported and working; existing sites need no change. Behaviours are idempotent, so activating twice costs one extra querySelectorAll.
Activation is always explicit — a behaviour module never starts itself. It used to, and inside a bundled React app that was a bug: the client chunk evaluates before React hydrates, so the TOC appended <li>s to a <ul> React believed was empty and the FAQ rewrote attributes, failing hydration (React #418) and forcing that subtree to be thrown away and re-rendered on the client. Fixed in 0.8.1. For an unbundled page with no framework, import core/behaviors/auto.js, which is the one file that starts them implicitly.
Two things the TableOfContents behaviour handles on its own, so no pipeline config is required:
- Scope — it scans the prose region containing it (
.bk-prose→.blog-root, falling back tobody), so a site needn't wrap the body in any particular tag. - Anchors — it mints ids for headings that have none, matching what
rehype-slugwould have produced. Server-rendered ids are still nicer (linkable from the first paint) but no longer a precondition for the entries to work.
Theming
A tenant's theme.json (in its content repo) is compiled to a stylesheet that overrides --blog-* tokens on .blog-root, loaded after core/css/index.css. Only brand colours + font families change; structure stays shared. See demo/index.html for the same components under the Capture (orange) and HJD (cyan) themes.
