@codegame.dev/careerflow-cms
| Install | |
|---|---|
npm install @codegame.dev/careerflow-cms |
|
| Latest Version: | 0.3.0 |
| Vue: | 3 |
| License: | MIT |
| Last Updated: | Oct 10, 2026 |
| Links: | GitLab · npm |
@codegame.dev/careerflow-cms
Framework-agnostic TypeScript client for the CareerFlow CMS, with first-class Vue 3 and Nuxt 3 support.
- Core client — zero dependencies, works anywhere (browser, Node, SSR).
careerflow-cms/vue— Vue plugin +useCareerFlow()composable.careerflow-cms/nuxt— Nuxt module: auto-registers the client plugin, the cachedcontent-bundleserver route, and the catch-all proxy route rule.
Install
npm install @codegame.dev/careerflow-cms
Core (framework-agnostic)
import { createCareerFlowClient } from "@codegame.dev/careerflow-cms"
const cms = createCareerFlowClient({
baseUrl: "https://cms.example.com",
companyId: 7
})
// Batched content queries — fully typed, keyed by query_name
const { post, list } = await cms.contentBundle([
{ query_type: "post", query_name: "post", slug: "about-us" },
{ query_type: "posts", query_name: "list", types: "news", limit: 10 }
] as const)
// Or use the sugar methods
const post = await cms.getPost({ slug: "hello", view: true }) // view: true counts a view
const posts = await cms.getPosts({ types: "news", page: 1, limit: 20 })
const category = await cms.getCategory({ slug: "projects" })
const categories = await cms.getCategories({ level: 1 })
// Submit a public (cms-type) form
await cms.createFormSubmission({
form_slug: "contact-us",
submission_data: { name: "Jane", message: "Hi" }
})
// Resized image URL served by the CMS
const url = cms.image(post.covers[0]?.path, 800, 600, "cover")
Options
| Option | Type | Description |
|---|---|---|
baseUrl |
string |
CMS origin. Trailing slash normalized. |
companyId |
number | null |
CareerFlow company (tenant) id. |
fetch |
FetchFn |
Custom fetch (defaults to globalThis.fetch). |
headers |
record or function | Headers sent with every request (e.g. auth token). |
contentBundleGetPath |
string |
Override the content-bundle GET URL (the Nuxt module uses this to route GET through its cached server route). |
Errors
CareerFlowHttpError— non-2xx HTTP response (.status).ContentQueryError— thrown by helpers that unwrap a single query when the CMS reportssuccess: false(.code,.errors).
Note: contentBundle() does not throw on per-query failures — it maps data (possibly undefined). Use contentBundleRaw() to inspect per-query success/code/errors.
Vue 3
// main.ts
import { createApp } from "vue"
import careerflowPlugin from "@codegame.dev/careerflow-cms/vue"
const app = createApp(App)
app.use(careerflowPlugin, { baseUrl: "https://cms.example.com", companyId: 7 })
// any component / composable
import { useCareerFlow } from "@codegame.dev/careerflow-cms/vue"
const cms = useCareerFlow()
vue (>= 3.3) is an optional peer dependency — only needed for the /vue entry and the Nuxt visual editor.
Nuxt 3
// nuxt.config.ts
export default defineNuxtConfig({
modules: ["@codegame.dev/careerflow-cms/nuxt"],
careerflow: {
// companyId: 7, // or CAREERFLOW_COMPANY_ID env
// serverBaseUrl: "https://cms.example.com", // or CAREERFLOW_BASE_URL env (preferred)
// browserBaseUrl: "api", // site-relative proxy path (default "api")
// serverProxy: true, // cached content-bundle route + proxy server handler (default true)
// cacheTTL: 60, // seconds, 0 disables (default 0)
// cacheDir: ".cache/api", // filesystem cache dir
// locale: "fa", // pin the [data-i18n] fill locale (else @nuxtjs/i18n, else site i18n cookie/localStorage, else "en")
// websiteSlug: "behran", // cms_websites.slug this site's texts belong to (or CAREERFLOW_WEBSITE_SLUG env)
// liveEditor: { enabled: true } // inert without a token; false hard-disables the code path
}
})
The plugin provides $careerflow (a CareerFlowClient), accessible via useNuxtApp().$careerflow:
const cms = useNuxtApp().$careerflow
const { data } = await useAsyncData("news", () => cms.getPosts({ types: "news", limit: 10 }))
What the module registers
- Runtime plugin — builds a client that fetches through the site's own proxy path (forwarding the original request's client-identifying headers on SSR calls). GET
content-bundlegoes through the cached server route; everything else goes through the proxy handler. - Cached server route
GET {browserBaseUrl}/content-bundle— filesystem-cached (cacheTTL), forwards a safe header allowlist (client IP, user-agent, …) upstream, and serves stale cache when the CMS is down. - Proxy server handler
{browserBaseUrl}/** → {CAREERFLOW_BASE_URL}/**— proxies everything else (POST content-bundle, form submissions, images). A server handler, not a route rule, so the exactcontent-bundleroute above wins. - Static texts fill + visual editor host — the runtime plugin fills
[data-i18n]nodes from the texts map (useCfTexts), captures?cf_editortokens intosessionStorage, and lazy-mounts the editing overlay only when a token is present.
Environment variables
| Env var | Purpose |
|---|---|
CAREERFLOW_BASE_URL |
Real CMS origin (server-only). Preferred over the serverBaseUrl option so deployments can override it. |
CAREERFLOW_COMPANY_ID |
Company (tenant) id. |
CAREERFLOW_BROWSER_BASE_URL |
Site-relative proxy path (default api). |
CAREERFLOW_CACHE_TTL |
Cache TTL seconds for the content-bundle route (default 0). |
CAREERFLOW_TEXTS_LOCALE |
Pin the [data-i18n] fill locale (fa/en/tr); overrides @nuxtjs/i18n and the site's i18n cookie/localStorage. |
CAREERFLOW_TEXTS_LOCALE_COOKIE |
i18n storage key(s), csv — read as cookies AND localStorage by both fill passes (default i18n_locale,i18n_redirected). Same as the textsSsr.localeCookie option. |
CAREERFLOW_WEBSITE_SLUG |
The cms_websites.slug this site's static texts belong to. Required for data-i18n texts — texts are per-website and the CMS rejects website-less texts queries. |
CAREERFLOW_LIVE_EDITOR |
Set false to hard-disable the visual editor overlay code path (default enabled). |
CAREERFLOW_TEXTS_SSR |
Set false to disable the SSR no-flash texts fill (default enabled). |
The filesystem cache works on VM/Docker deployments. On serverless targets the route still proxies and forwards headers, but the stale-on-failure cache is best-effort.
Static texts (data-i18n)
Mark any element with a data-i18n attribute in your plain markup — no component or directive needed.
Keys are flat and dotted (home.hero.title); the element's own text is the fallback shown whenever the
key has no stored value for the current locale, so unedited sites render exactly as before:
<h1 data-i18n="home.hero.title">Lifting solutions since 1974</h1>
<a href="/services" data-i18n="nav.services">Services</a>
<input type="search" placeholder="Search…" data-i18n-placeholder="home.search.placeholder">
<button aria-label="Open menu" data-i18n-aria="nav.menu.aria">☰</button>
Beyond textContent, the attribute suffix routes the fill into an element attribute:
data-i18n-placeholder="key" fills placeholder, data-i18n-aria="key" fills aria-label. The
stored shape is unchanged — one string per locale per key — so a key marked both ways stores a single
value that lands in both slots; give the input its own key when the placeholder should differ from a
nearby heading.
The module fetches the website's whole texts map (every key, every locale) once during SSR and ships it
in the Nuxt payload — the browser never refetches it on hydration, route changes or locale switches —
then fills every marked node's slot, re-running on SPA route changes. With the SSR no-flash fill on
(below), that SSR fetch reuses the fill's short-lived per-website cache, so upstream texts traffic is
about one request per textsSsr.cacheTtl window instead of one per pageview. refresh() and the live
editor always fetch uncached (POST content-bundle). Site code can reach the same state via the auto-imported useCfTexts():
const texts = useCfTexts()
await texts.refresh() // re-fetch the map from the CMS
texts.fill() // re-run the DOM fill pass
texts.apply("nav.services", "fa", "خدمات") // optimistic local update (visual editor save path)
texts.setLocale("fa") // pin the fill locale; null follows the site again
Locale wiring: the fill locale resolves as module option locale / env CAREERFLOW_TEXTS_LOCALE →
the current @nuxtjs/i18n locale when that module is installed (behran-landing runs en/fa this way) →
the site's i18n cookie/localStorage entries (keys default to i18n_locale,i18n_redirected; configurable
via textsSsr.localeCookie / CAREERFLOW_TEXTS_LOCALE_COOKIE — csv, each key checked as a cookie and in
localStorage) → "en". Only fa/en/tr count, so a site running another language simply falls through.
With @nuxtjs/i18n, switching the site locale re-fills the marked texts automatically.
Website scoping: texts are stored per website — every texts query carries the
cms_websites.slug from module option websiteSlug / env CAREERFLOW_WEBSITE_SLUG, and visual-editor
saves are scoped the same way. Set it to the slug shown for the site's website row in the CareerFlow
panel; without it the CMS rejects texts queries (a dev warning fires once).
SSR no-flash fill: by default a nitro plugin also rewrites the rendered HTML before the response
ships, so the first paint already shows stored values — no one-frame flash of the markup fallback
while the client-side pass waits for hydration. It parses only pages that contain data-i18n
markup, applies the same slot rules as the client pass (text/placeholder/aria, markup stays the
fallback for untranslated keys), and keeps the edited regions' surroundings byte-identical. Each filled element also gets a
data-i18n-fallback attribute holding its markup value; the client pass reads it (then removes it) so a
value later cleared in the visual editor falls back to the markup, not to the server-filled text. The
per-request locale resolves identically to the client pass minus $i18n and localStorage (servers
have neither): option locale → the configured i18n cookies → accept-language → "en" — a client
re-fill after mount covers the gap. The texts map is cached
per website for textsSsr.cacheTtl seconds (default 30, so visual-editor saves reach visitors' fresh
renders within that window). A logged-in visual editor refetches the map uncached the moment the overlay
mounts, so editors always work on live values.
Disable with textsSsr: { enabled: false } or CAREERFLOW_TEXTS_SSR=false.
Visual editor
CareerFlow editors can edit these texts visually on the real site. The panel mints a short-lived editor
token and opens https://your-site/?cf_editor=<token>; on boot the module stashes the token in
sessionStorage, strips it from the URL, and lazy-loads the overlay (visitors without a token never
download it). The overlay shows a session chip, a toolbox panel (locale switcher, list
of the page's marked texts, "Open in CareerFlow", a deep link when the page is a post), and lets the
editor click any marked text to edit fa/en/tr inline — saves go straight to the CMS through the site's
same-origin proxy and update the page without a reload.
- Enabling: on by default — it is inert without a token. Hard-disable with
liveEditor: { enabled: false }orCAREERFLOW_LIVE_EDITOR=false. - Locale source: the overlay's editing UI offers all of fa/en/tr regardless of the site's locales; the page fill follows the same locale wiring as above, and the toolbox switcher can pin a preview locale.
- Post deep link: when a
postcontent query resolves for the current route, the toolbox offers "Edit this post in CareerFlow" (best-effort; hidden when unknown). - Interface language: the overlay UI ships in English and Persian (RTL). It follows the site's language —
the live
$i18nlocale, then your configured locale cookie/localStorage keys, then common keys (i18n_locale,i18n_redirected,locale,lang,language,NEXT_LOCALE), then<html lang>and the browser language. Other languages fall back to English. Editors can switch it from the toolbox footer; that choice is remembered inlocalStorage(cf-live-editor-ui-locale). - Edit mode: the chip's "Edit" toggle pauses editing so links and buttons work normally while browsing the site. Ctrl/Cmd-click on a marked link always keeps its browser behavior.
- Editing: Ctrl/Cmd+Enter (or Ctrl/Cmd+S) saves, Esc closes. Closing or navigating drops unsaved edits without prompting. Logging out asks for confirmation in an in-overlay dialog — the overlay never uses native browser dialogs.
- Vue version: the overlay runs on the site's own
vue; it ships precompiled render functions and no Vue runtime or template compiler. It works with Vue 3.3.0 and later (checked against 3.3.0 and 3.5), which matches thevue >= 3.3.0peer range and every Vue that Nuxt 3.9+ allows.
Security model: the editor token is opaque, scoped, and revocable — the CMS stores only its hash,
it slides: the site revalidates every few minutes so the session stays alive exactly as long as the
editor keeps the page open, and lapses 30 minutes after the page closes; panels can revoke a session
at any time. It lives in sessionStorage only (dies with the tab), never touches localStorage or
cookies, and is stripped from the address bar immediately. All editor traffic rides your site's own
{browserBaseUrl} proxy, so the CMS origin stays unexposed and visitors' requests are unaffected.
Overlay architecture
- Lazy chunk: the overlay is one file,
dist/nuxt/runtime/live-editor.js. The runtime plugin imports it dynamically only when an editor token exists, so visitors never load it. Its only top-level import isfrom "vue", and the bundler merges every vue import into that one.live-editor.tsimportseditor-vue.tsfirst, so the vue import sits at the very top. A second import statement further down the chunk gets mangled by the consuming app's import rewrite.npm run smokechecks this. - Build: tsup builds the client, the Vue entry, the module and the rest of the runtime. Vite
(
vite.config.ts) builds only the overlay chunk, with@vitejs/plugin-vueand Tailwind through PostCSS.vuestays external, and nothing else is bundled as a runtime UI library. - Shadow DOM: all overlay chrome (chip, toolbox panel, inline pencil and popover, confirm dialog) is
one Vue app (
ui/OverlayApp.vue) mounted in one shadow root under#cf-live-editor-root(ui/shadow.ts). Toasts get their own small shadow host ondocument.body, so they outlive the overlay on logout. Shared state lives in one reactive store (ui/overlay-store.ts), provided to the app. - Tailwind scope: Tailwind v3 (
tailwind.config.js) scans onlysrc/nuxt/runtime/ui/. The theme has semantic color tokens only (accent,ink,line,surface, ...), and the default palette is replaced. The compiled CSS, preflight included, is inlined asoverlay.css?inlineand injected only into the shadow roots. It never reaches the site, and the site's styles never reach the overlay.dir/langare set inside the shadow root, so thertl:variants work. - Light DOM: the only styles outside a shadow root are the ones that target the site's own marked
nodes: hover/active outlines, the edit-mode cursor and the reveal flash. They live in
host-styles.ts, are injected once when the overlay starts, and are removed on teardown. - Host interaction:
inline-controller.tsowns the document listeners on the site (hover/click on[data-i18n], outside clicks, keys, scroll/resize, route changes). The Vue components only render what it reports.
Development
npm run format:check # prettier, src/ and test/
npm run typecheck # vue-tsc --noEmit
npm run build # tsup + vite build + copy-runtime
npm run smoke # checks on the built dist (single vue import, markers, inlined Tailwind)
npm test # vitest unit tests (happy-dom)
npm run e2e # overlay end-to-end in the playground (run `npm run build` first)
Playground: playground/ is a small Nuxt app with data-i18n markup on two routes. It loads the
built module from ../dist, so rebuild after each change. No CMS origin is set, so its Nitro routes
under playground/server/api/cms/ mock the CMS: the texts bundle, session validate/logout, and saves
(kept in memory). npm run playground starts it in dev mode. Open /?cf_editor=test to get the overlay.
e2e: npm run e2e builds the playground, serves it on a free port, and drives it with
playwright-core. It uses Playwright's Chromium when installed, else the system Chrome. Set
CF_E2E_BROWSER to pick another Chromium-based executable.
Testing in a real site with a file: install: npm install ../careerflow-cms (a file:
dependency) installs a copy of the package, not a symlink, and the dev server does not watch
node_modules. After npm run build here:
- Run
npm installagain in the site, so it copies the newdist. - Restart the site's dev server. Vite keeps the overlay chunk it already served in memory.
- If the old overlay still shows up, delete the Vite cache (
node_modules/.cache/vitein Nuxt) and restart again.
License
MIT
Related Packages
Version History
| Version | Released | Vue | License |
|---|---|---|---|
| 0.3.0 | >=3.3.0 | MIT | |
| 0.2.0 | >=3.3.0 | MIT | |
| 0.1.3 | >=3.3.0 | MIT | |
| 0.1.2 | >=3.3.0 | MIT | |
| 0.1.1 | >=3.3.0 | MIT | |
| 0.1.0 | >=3.3.0 | MIT |