Fluidd is a Vue 2.7 + TypeScript web interface for Klipper 3D printers that communicates with Moonraker via WebSocket.
- Vue 2.7 + Vuetify 2: UI framework with Material Design components
- Vuex Store: 28 namespaced modules mirroring Klipper/Moonraker domains (
printer/,files/,console/,macros/,webcams/,mmu/,spoolman/, etc.) - WebSocket Communication: Real-time JSON-RPC via custom
WebSocketClientinsrc/plugins/socketClient.ts - Component Structure: Class-style components with
vue-property-decorator; mixins-based architecture withStateMixinproviding common printer state access
All components use class-style decorators — no Options API or Composition API:
// Standard component
@Component({ components: { /* ... */ } })
export default class MyComponent extends Vue {
@Prop({ type: String, required: true })
readonly label!: string
@VModel({ type: Boolean })
open?: boolean
}
// Component needing printer state — extend via Mixins()
@Component({ components: { /* ... */ } })
export default class PrinterWidget extends Mixins(StateMixin) {
get klippyReady (): boolean {
return this.$typedGetters['printer/getKlippyReady']
}
}Available mixins (src/mixins/): StateMixin, FilesMixin, ServicesMixin, BrowserMixin, CameraMixin, ToolheadMixin, AfcMixin, MmuMixin
- Store modules in
src/store/— each hasstate.ts,getters.ts,mutations.ts,actions.ts,types.ts - Use
$typedStateand$typedGettersfor type-safe store access (defined insrc/plugins/filters.ts) - Use
Vue.set()for reactive dynamic state properties - Module definition:
export const auth = { namespaced, state, getters, actions, mutations } satisfies Module<AuthState, RootState>
- All printer communication through
SocketActionsinsrc/api/socketActions.ts(not direct HTTP) - Pattern:
baseEmit(method, { dispatch, wait, params })—methodmust be a key ofMoonraker.Methods;paramsand the result type are inferred from it, never passed as a type argument Moonraker.Methodsmaps every JSON-RPC method Fluidd calls to{ params, result }, built by declaration merging: eachsrc/typings/moonraker.*.d.tsadds its entries in a separatedeclare namespace Moonraker { interface Methods { … } }block. Every entry declares both fields —params: undefinedwhen the method takes none. Params are typed from the Moonraker docs, not from what Fluidd sends. Helpers:Moonraker.Method,MethodParams<M>,MethodResult<M>skipLibCheckhides errors in.d.tsfiles, so an entry missingparamsorresultonly fails viaCheckedMethodsinsocketClient.ts— the error does not name the method. A conflicting duplicate method key is not caught at all- Params are required in
EmitOptions<M>when the method has a required param, optional when all are optional, and rejected when it takes none.RequestOptionsitself carries noparams. A response reaches the store only throughdispatch— there is nocommitoption - Generic results (
server.database.*_item,server.spoolman.proxy,server.extensions.request) areunknownin the map; narrow at the call site withbaseEmit<'method', Narrower>(…)—Narrowermust extend the map's result - Dispatched handlers type their payload as
ObjectWithRequest<'method'>, which types both the result and__request__.params(optional params may beundefined; default them to Moonraker's documented defaults). A string result is dispatched as{ result } Moonraker.Notificationsmaps each JSON-RPC notification Fluidd handles to its params tuple (undefinedwhen Moonraker sends none — it omitsparamsentirely), merged permoonraker.*.d.tslikeMethods.socketClient.tsdispatchessocket/<camelCase(method)>with the first param only (notify_status_updateexcepted: it is cached and flushed every second, using itseventtime); thesocket/handlers type it asMoonraker.NotificationPayload<'notify_…'>(so they follow the map; helpers:Moonraker.Notification,NotificationPayload<N>,NotificationAction<N>— thesocket/action name, viaTSHelpers.SnakeToCamelCase), while the module actions they forward to name the concrete type — both spellings are intentional.CheckedNotificationHandlers(src/store/socket/actions.ts) fails type-check, naming the handler, when an entry has nosocket/action;CheckedNotifications(socketClient.ts) checks the entry shape. Notifications outside the map (notify_sudo_alert,notify_button_event, …) still reachdispatchand are dropped as unknown actions- Moonraker agents: the
extensionsmodule holds connected agents, updated bynotify_agent_event's reservedconnected/disconnectedevents; every agent event, reserved ones included, is then routed to the matchingGlobals.MOONRAKER_AGENTSentry'seventDispatch.extensions/onExtensionsListalso sends each listed agent'seventDispatcha synthesizedconnectedevent, so an agent that connected before Fluidd listed the agents (or before a reconnect) is seen the same way as one connecting live. Symmetrically,extensions/reset(socket drop, instance switch) sends each known agent'seventDispatcha synthesizeddisconnectedbefore clearing the list. No deduplication is done — a liveconnectedcan race the list, so agent handlers must be idempotent for repeatedconnected/disconnected.Extensions.AgentEvent's catch-allevent: stringdefeats discriminant narrowing — narrow with type predicates - Payload types used only by notifications end in
Event, named after the notification with the namespace prefix dropped (notify_job_queue_changed→JobQueue.ChangedEvent,notify_proc_stat_update→ProcStats.UpdateEvent). A type shared with a method result keeps itsResponsename.*Eventtypes are standalone interfaces — declare every field, neverextends/alias/PickaResponse, even when the shape matches (Announcements.DismissedEventvsDismissResponse).Authorization.UserEventis the one content-named exception, shared by the three user notifications notify_user_logged_out/notify_user_deletedare broadcast to every client, but Moonraker only de-authenticates that user's connections —auth/onUserLoggedOutandauth/onUserDeletedlog out only when theusernameis the current user.access.get_useranswers{ username: null, … }for a connection with no user;auth/setCurrentUserstores that asnull.auth.currentUserandauth.usersareAppUser(src/store/auth/types.ts, nocreated_on): neither theaccess.loginresponse nornotify_user_createdcarries it.notify_user_createdonly fires for users created through the API, which Moonraker restricts to themoonrakersource, sosetAddUsersets thatsourceitself- Use
waitparameter for UI loading states:wait: Waits.onPrintStart - Wait constants defined in
src/globals.ts(Waitsobject, ~90 operation types) - Real-time updates handled via store mutations from socket events
- Auto-reconnect (
socketClient.ts) retries forever, staying inconnecting: exponential backoff from 1s, ±20% jitter, capped at 15s after the jitter (Fluidd is LAN-local with few clients, so retries are cheap and a long cap only delays noticing a printer is back), reset on open. A retry that comes due while the tab is hidden or the browser is offline is held instead of opened (socket.retry={ held: true }), and resumes onvisibilitychange/online. A pending retry fires immediately ononline, when the tab becomes visible, or fromSocketDisconnected's Retry now button (retryNow(), a no-op without a pending or held retry).socket.retry={ at }(epoch ms) is set only for waits of 5s or more (RETRY_SHOW_AFTER), and drives the button and the "Unable to connect to Moonraker. Retrying in X seconds..." countdown, so the first quick retries don't flicker them. While held, the button stays up and the message reads "Unable to connect to Moonraker.".disconnectedis reached only for an empty URL or anappInitfailure - Liveness uses one timer at a time, re-armed by any inbound frame (a JSON-RPC error counts): 10s of silence sends a
server.infoping — unauthenticated sockets get no notifications, so only a ping produces traffic — then 10s more of silence replaces the socket.socket.stalledis true while that ping is outstanding (set by the ping timer, cleared byclearSilenceTimer, so any frame,handleCloseorcloseresets it);SocketStalledSnackbarshows it over the main app only, as the connecting and login screens have their own state. The ping timer is not armed untilonopen, sincesendthrows whileCONNECTING; the same 10s timer bounds a socket stuck inCONNECTING. The two stages are chained, not parallel, so timers that come due together after sleep or a frozen tab still give the ping its 10s. Every socket handler ignores events from a socket that is no longerthis.connection RequestOptions.suppressError(boolean | (error: SocketError) => boolean) skips the globalsocket/onSocketErrortoast for that request — the emit promise still rejects, callers still need acatch. Acode >= 500is never suppressible (a 503 drives klippy recovery insocket/onSocketError), and a predicate that throws logs via consola and falls back to not suppressing. Narrow a caught rejection withisSocketError(src/util/is-socket-error.ts) — seehistory/fetchMissingJobssuppressing not-found errors fromserverHistoryGetJob. Moonraker's JSON-RPC layer rewrites a 404 to-32601and a 401 to-32602(other codes pass through), so test withisMoonrakerNotFoundError/isMoonrakerUnauthorizedError, never the HTTP code.is-socket-error.tsownsSocketErrorand must stay import-free —socketClient.tsimports the store types, so pulling it into a spec drags the whole store into the vitest type-check project
- Auto-imported (no manual import needed): components in
src/components/common/,layout/,ui/— viaunplugin-vue-componentswithVuetifyResolver - Manual import required: widget components, view components
- Lazy-loaded:
EChartviaVue.component('EChart', () => import('./vue-echarts-chunk')) - Generated types:
components.d.tsat repo root — auto-generated, do not edit manually
- Node.js 26 — pinned in
package.jsonviadevEngines.runtime^26.13.0withonFail: download(engines:^24 || ^26). pnpm provisions it; there is no.node-versionfile and Corepack is not used anywhere — the pnpm version itself is pinned the same way, throughdevEngines.packageManager. SettingpackageManageras well is an error in pnpm 12 ("Cannot use both"), so the two fields are mutually exclusive - Vite 8 — build tool and dev server
@pedrolamas/plugin-vue2— Vue 2 SFC support for Viteunplugin-vue-components/rolldown— auto-imports components fromsrc/components/common|layout|uisass-embedded— SCSS preprocessor (variables auto-injected via@/scss/variables)- vitest v5 — unit test runner;
test:unitis a barevitestcall, with the environment, pool and setup files declared invitest.config.ts - pnpm catalog (
pnpm-workspace.yaml) —dompurify,echarts,typescript,viteandvueare pinned in the catalog and blanket-mapped throughoverridesso every transitive dependency resolves to the same version typescript-native-bridge— thetypescriptcatalog entry isnpm:typescript-native-bridge@…(tsgo), not stocktypescript- Local config imports use explicit
.tsextensions (./vite.config.ts) andimport.meta.dirname— no__dirname commit-and-tag-version— release versioning (pnpm run release)- ESLint flat config (
eslint.config.mjs) — enforced at dev time viavite-plugin-checkerwithuseFlatConfig: true vite-plugin-checker— runs vue-tsc and ESLint during dev (disabled at build time)skott— circular dependency detection (pnpm run circular-check)- ES2020 lib target (
tsconfig.app.json) — no ES2021+ built-ins without polyfills vite.config.ts'sbuild.rolldownOptions.output.codeSplitting.groupspinslodash-es,vue/vue-router/vuex/vue-i18n(asvue-vendor), andvuetifyinto named vendor chunks; everything else (monaco, echarts, hls.js, iro, qr-scanner, …) deliberately stays on rolldown's default lazy chunking — do not sweep those into a group
pnpm run prepare # Install git hooks (runs automatically on `pnpm i`)
pnpm run dev # Start development server (port 8080)
pnpm run build # Production build
pnpm run type-check # TypeScript validation (vue-tsc)
pnpm run lint # ESLint with Vue/TS rules
pnpm run test # Vitest unit tests
pnpm run circular-check # Check for circular dependenciessrc/
├── api/ # WebSocket (custom JSON-RPC) client (`socketActions.ts`)
├── components/
│ ├── common/ # Shared dialogs & status components (auto-imported)
│ ├── layout/ # App shell: AppBar, AppDrawer, etc. (auto-imported)
│ ├── settings/ # Settings page components
│ ├── ui/ # Reusable: AppBtn, AppDialog, AppChart (auto-imported)
│ └── widgets/ # 27 feature widget dirs: bedmesh/, camera/, console/, filesystem/, macros/, mmu/, thermals/, toolhead/, etc.
├── directives/ # Custom Vue directives (v-safe-html for DOMPurify)
├── locales/ # i18n YAML files (24 languages)
├── mixins/ # Vue mixins (StateMixin, FilesMixin, etc.)
├── monaco/ # Monarch tokenizers and editor themes
├── plugins/ # Vue plugins (i18n, socketClient, vuetify, filters, colorSet)
├── router/ # Vue Router (hash mode) — no route-level auth guards
├── scss/ # Global styles and Vuetify variable overrides
├── store/ # 28 Vuex modules (printer, files, config, webcams, etc.)
├── types/ # UI-specific TypeScript types
├── typings/ # Global .d.ts declarations (Klipper, Moonraker namespaces)
├── util/ # Helper functions (30+)
├── views/ # Page components (Dashboard, Console, Jobs, etc.)
└── workers/ # Web Workers (parseGcode, mjpegStream, sandboxedEval, Monaco language providers)
- Hash-based routing (
#/path) - Views lazy-loaded via dynamic imports:
component: () => import('@/views/X.vue') - No route-level auth guard — authentication is handled entirely by
App.vue:socketReady→ render main app;socketAuthenticating→ render Login overlay; else → renderSocketDisconnected. Navigating to/loginis redirected tohome(the route no longer exists) - Socket state machine (
src/store/socket/actions.ts):initializing → {connecting | disconnected} → identifying → {ready | authenticating}.initializingis the one-shot startup state; the app begins here and leaves it once$socket.connect()runs — transitioning toconnecting(valid URL) ordisconnected(empty URL). Every transition goes throughsocket/onSetStatus, which validates the edge againstVALID_TRANSITIONS, commits the new status, and runs side-effects for the destination state. Enteringconnectingclears per-socket identity (connectionId,acceptingNotifications);ready → connectingadditionally resets modules holding live data (charts/resetChartStore, which clears only the thermal bucket, plusMODULES_TO_RESET_ON_DROP). Re-runningappInit(instance switch) callsstore.dispatch('reset')which mutates socket state back toinitializingdirectly viasetReset, bypassing the state machine - JWT auth over WebSocket:
runIdentifysendsserver.connection.identifywith the stored user token (refreshed proactively if expired); if both tokens are expired, identify is called without a token (anonymous/trusted identify). On success it awaits the post-auth bootstrap — every Moonraker DB namespace Fluidd owns (skipping any with empty ROOTS), thenserver.info,server.config,machine.proc_stats,machine.system_info, andserver.files.list('config')— before transitioning toready; on failure →authenticating.server.connection.identifyis one-shot per socket, so the logout→login path (same physical socket, new user viaaccess.login) skips the identify call (connectionId already set) and runs only the bootstrap before transitioning toready. Thereadytransition itself has no side-effects — enteringreadysimply unblocks the main app render auth/loginstores the fresh tokens then dispatchessocket/onSetStatuswithidentifyingto re-identify the live socket.auth/logout(full) transitions the socket toauthenticating; the socket stays open andApp.vuerenders the Login overlay whilesocket.status === 'authenticating', so login continues over the existing connection viaaccess.info/access.login.notifyUserLoggedOut(fired when Moonraker invalidates the session) does the same- Token refresh policy (
getAccessTokeninsrc/store/socket/actions.ts): valid access token → use it; expired access token + valid refresh token → callaccess.refresh_jwtand use the new token; refresh rejected as unauthorized (isMoonrakerUnauthorizedError) (or both tokens unusable) → clear both from localStorage and identify anonymously; refresh rejected with anything else (transient: socket drop, network) → keep tokens, returnnull, let the next identify cycle retry - Key routes:
/,/console,/jobs,/tune,/diagnostics,/timelapse,/history,/system,/configure,/settings,/camera/:cameraId,/preview
- MDI icons via
@mdi/js— mapped insrc/globals.ts(Iconsobject, ~234 mappings) - Usage:
<v-icon>{{ $globals.Icons.close }}</v-icon> - Vuetify theme with custom dark/light overrides in
src/scss/variables.scss - PWA support with service worker in
src/sw.ts(Workbox, injectManifest strategy)
- Setup in
src/components/widgets/filesystem/setupMonaco.ts(includes worker environment setup) - Monarch tokenizers for
gcode,klipper-config,moonraker-config,loglanguages (insrc/monaco/language/*.monarch.ts) src/monaco/language/gcode-rules.tsis the shared G-code tokenizer factory —createGcodeRules('standalone')backs thegcodelanguage,createGcodeRules('embedded')is spliced intoklipper-configvaluesklipper-confighighlights any option whose name ends ingcode(gcode,*_gcode) as a Klipper G-code template —gcode_id,gcode_x_offsetandgcode_load_sequencemerely start with it and stay plain values- Klipper's Jinja uses single-brace delimiters (
{ … }, not{{ }}), so these rules are not reusable for standard Jinja; both hosts must setignoreCase: trueat language level because Monarch drops per-rule regex flags - Both editor themes in
setupMonaco.tscarrydelimiter.jinja,keyword.control.jinja,variable.jinjaandnumber.jinjatoken colours - Custom CodeLens providers (links to Klipper/Moonraker docs from config sections)
- CodeLens and document symbol providers for
klipper-configandmoonraker-config; folding range provider forklipper-config,moonraker-config, andgcode - Language providers run in dedicated Web Workers (
monacoCodeLensWorker,monacoDocumentSymbolsWorker,monacoFoldingRangesWorker)
- Parsed in
src/workers/parseGcode.worker.ts→parseGcode.ts— streamed viafetch+ReadableStream, reporting progress by file position - Result is a columnar
MoveStoreof typed arrays, notMoveobjects (src/store/gcodePreview/types.ts):x/y/z/i/jFloat32Array,tool/flags/role/fanUint8Array,speedUint16Array,filePositionUint32Array, plus alength— every column is indexed by the same move index roleis anExtrusionRole(PrusaSlicer's 15-role enum, Brim folded into Skirt), sticky from the last role comment.src/workers/gcode-extrusion-roles.tsmerges per-slicer tag lists, taken from each slicer's source, into one case-insensitive dictionary — no slicer detection. A spec ensures a tag shared by two slicers maps to the same role; the code assumes it does.parseLinereturns whole-line comments as a separatecommenttype; slicers never put a role tag after a commandspeedis the sticky feedrate in mm/min, clamped to0xffff(MAX_SPEED) — a typed array write wraps rather than saturates. The UI shows mm/sfanis the sticky part cooling fan as the rawM106 Svalue (0–255), with Klipper'sM106semantics: a missingSis 255,Pis ignored,M107is 0;SET_FAN_SPEEDonly drives[fan_generic], so it is ignored- The result also carries
roles(sorted distinct roles of extruding moves) plusspeedRangeandfanRange(ValueRange: min/max over extruding moves, whole file — keeps the legends stable across layers — plus PrusaSlicer'scount, exact only up to 2 distinct values) x/y/zare forward-filled absolute positions after each move — there are noundefinedaxes to fall back throughi/jare arc centre offsets, truncated after the lastArcmove — only read them when that move has theArcflag setMoveFlagsis a bit field:Extruding,Retracting,Arc,Clockwise— test with(flags[i] & MoveFlags.Arc) !== 0- The worker transfers the
MoveStorebuffers viapostMessagetransferables rather than copying them; the parser owns nothing afterwards.defaultMoveStore(gcodePreview/state.ts) is the empty store used on reset - A layer's
moveis an anchor — the move whose endpoint is the layer start, i.e. the move before the layer's first real move.buildLayerPathsboth seeds the toolhead from it and iterates from it, drawing it as a zero-length segment - SVG path data is built by
buildLayerPathsinsrc/util/gcode-preview.ts(extracted from the oldgetPathsgetter, which now just delegates). Every emitteddmust start with a moveto — adbeginning withLorArenders nothing LayerPaths.extrusionsis keyed by colour group, chosen bycolorBy(uiSettings.gcodePreview.colorMode):T<tool>,R<role>,S<bucket>orF<bucket>, whererangeBucketrounds to the nearest of the 11 evenly spacedrangeColorsstops overspeedRange/fanRange, using PrusaSlicer's range palette. The legends list the stop values like PrusaSlicer's (mm/s, and percent for the fan), or just the values when there are 1 or 2 distinct ones. Colours come fromgcodePreview/getExtrusionColors; only the current layer is coloured, the other layers stay grey- There is no colour-mode fallback: a file without role tags or extruding moves draws in one
colour (
R0/S0/F0) with an empty legend - Oversized files are truncated by the parser, which sets
truncatedon the result;gcodePreview/actions.tssurfaces it as anEventBuswarning (app.general.msg.gcode_preview_truncated)
- Chart data lives in a columnar ring buffer of
Float64Arrays (src/util/chart-buffer.ts), not an array of point objects — atimearray plus aMap<string, Float64Array>of columns, all indexed by the same sample index - The live window is
[offset, offset + count), shared bytimeand every column. Read throughchartBufferSource/chartBufferColumn/chartBufferLastValue/chartBufferLastTime, which applyoffsetfor you — never index a column directly revisionis the only reactive change signal. Vue 2 observes neither typed arrays nor aMap, and the arrays aremarkRaw'd so the deep watcher skips them — writes are invisible to Vue.chartBufferSourcereadsrevision, which is what re-runs a consuming computed;ThermalChartalso@Watches it, thoughinitSeriesthen early-outs unless the columnMaporgetChartableSensorsactually changed. Nothing goes throughVue.set- Missing values are
NaN, neverundefinedand never a hole in the index — a sample carrying no value for a column still advances every column columnsis aMapfor own-key semantics: on a plain object a sensor namedconstructorortoStringpassesinand its column is never created- The
chartBufferSource/smoothChartSourceresult must keepObject.prototype— neverObject.create(null). ECharts'setOptiondeep-clones the whole option through zrender'sclone, which callssource.hasOwnProperty(key)and throws on a null-prototype object. That clone also skips a key literally named__proto__, so a sensor by that name cannot be charted regardless — not worth defending against chartBufferSourcefeeds ECharts'SOURCE_FORMAT_KEYED_COLUMNSdataset.sourceas subarray views.datemust be dimension 0; ECharts derives the row count from column 0. Results are memoized perChartBufferbyrevision. The views only avoid a copy on our side —setOptiondeep-clones the whole option, andclonecopies typed arrays viaCtor.from- Incremental
datasetupdates must pass{ replaceMerge: 'dataset' }. A plain merge runs zrender'smerge, whose array guard isArray.isArray— aFloat64Arrayfails it, somergerecurses into the column and copies index by index. A source shorter than the one ECharts already holds then keeps stale trailing rows, which duplicate earlier timestamps and make the tooltip report two points per series - With a keyed-columns source a tooltip
param.valueis a positional array, not a row object — resolve it via theencode/dimensionNameshelpers insrc/util/chart-tooltip.ts dropExpiredbinary-searchestime, so samples must be appended in non-decreasing time order. Appending a backlog behind live samples breaks that, so the thermal history load replaces the buffer wholesale (setThermalStore).machine.proc_statsre-runs on every reconnect, socharts/onMoonrakerStatsinstead appends only the stats past the buffer's tail (moonrakerChartSamples), keeping the accumulated window; the same action handles the live single-stat notification, so there is one ingestion pathappendChartSamplesis the only append primitive — it takes a batch and bumpsrevisiononce for the whole thing, so a backlog is one re-render, not one per sample.setChartEntryis the single-sample mutation over it;setChartEntriestakes the batchretentionis seconds everywhere, never a sample count.capacityForuses it as a first-guess capacity only; samples can arrive faster than 1Hz, andcompactgrows the arrays when they do.resizeChartBuffertherefore keeps the whole live window and leaves expiry todropExpiredon the next append- History conversion is pure and lives outside the store plumbing:
thermal-history.tsbuilds a whole buffer fromserver.temperature_store, writing columns directly and publishing withcommitChartSamples;moonraker-history.tsonly mapsmachine.proc_statstoChartSamples - Thermal history is right-aligned on a 1Hz timeline ending at
endTime - 1000, with the lead-in held at each sensor's oldest non-null reading rather than leftNaN. That padding is load-bearing:ThermalChart's x-axis ismax: 'dataMax'withmin = max - retention * 1000, so the window is always retention-wide and an unpadded short history renders as a stub in the corner server.temperature_storesamples may benull; they becomeNaNgaps, never0- Thermal column names are
<sensor>or<sensor>#<target|power|speed>— build and parse them withthermalColumn/parseThermalColumn(src/store/charts/thermal-columns.ts); sensor names are runtime data and may themselves contain# - Retention:
Globals.CHART_HISTORY_RETENTION(1200s — thermal, diagnostics) andGlobals.CHART_SYSTEM_RETENTION(600s — system, MCU, sensor). Onlythermalcarriesretentionon itssetChartEntrypayload — theChartBucketunion makes passing one for any other bucket a type error — because only the thermal window is server-configured, overridden by Moonraker'stemperature_store_sizethrough thecharts/getChartRetentiongetter. Diagnostics is fixed atCHART_HISTORY_RETENTION, soDiagnosticsCard's x-axisminuses the constant too; every other bucket keeps whateverstate.tsgave its buffer charts/resetChartStoreresets onlythermalandready, not the whole module — it is shared by the socket drop and rootresetKlippy, and a klippy restart does not re-fetchmachine.proc_stats, so blanking the other buckets would leave them with nothing to refill fromsmoothChartSource(src/util/chart-smoothing.ts) is a trailing moving average overuiSettings.general.chartSmoothingWindowseconds (0 = off, the default);NaNvalues stayNaNrather than pulling the average down. Only bare thermal columns are smoothed —ThermalChart'ssmoothableKeysfilters toparseThermalColumn(name).sub == null, so target temperatures, heater power, and fan speed stay exact.ThermalChart@WatcheschartRevisionandchartSmoothingWindow(the raw inputs), not the smoothed source, so the paused check can skip the recompute entirely
- All printer commands via
SocketActionsmethods — both init and live data flow over a single WebSocket - Store updates from WebSocket events (not polling)
- File operations through Moonraker's file API (
src/store/files/) - File uploads/downloads are the sole consumer of
axios— direct calls insrc/mixins/files.ts(for upload/download progress, whichfetchcannot report for uploads), authenticated with a oneshot token fetched viaSocketActions.accessOneshotToken(). There is no HTTP client plugin, and the rest of the app usesfetchor the WebSocket
Globals.JOB_HISTORY_LOAD(100) bounds the initialserver.history.listfetch;history/onHistoryListderivesstate.allLoadedfrom the echoed request'slimit(viaObjectWithRequest.__request__.params, not the response), so a short page (orlimit: 0, used by "Load all") both flip it correctly- A job referenced by a file in the loaded file listing but outside the loaded history window is back-filled individually:
history/fetchMissingJobsdiffs against loaded jobs andstate.unresolvedJobIds, then callsSocketActions.serverHistoryGetJobper missing id, suppressing not-found errors (suppressError: isMoonrakerNotFoundError) since a file can reference a job Moonraker has since pruned - Those requests go out in chunks of
Globals.JOB_HISTORY_FETCH_CHUNK, and each chunk lands as onesetUpdateHistoryJobscommit.serverHistoryGetJobdeliberately has nodispatch— a per-response commit re-runsgetHistoryandfiles/getDirectoryand re-renders the file table once per job, which froze large directories.unresolvedJobIdsholds ids both in flight and confirmed not-found — a success or any other failure removes the id, a not-found leaves it.onHistoryListdeliberately never clears it: that would re-send in-flight requests and re-ask pruned jobs - Job lookups go through
history/getHistoryByIdMap(a memoizedMap), not a linear scan ofgetHistory - CSV export (
JobHistory.handleExportCsv) always exports the whole history — it awaitsserverHistoryList({ limit: 0 })first when!allLoaded(the emit resolves afterhistory/onHistoryListhas committed) — with filename plus the columns visible in the column picker, ignoring the search box.getExportValuereturns raw values in base units, with no units in the headers, and aDatefor the date columns.Waits.onHistoryExportis added/removed by hand rather than via the request'swait, as it spans the fetch, the build and the download toCsv(src/util/csv.ts, import-free) owns every cell representation: RFC 4180 quoting, CRLF rows,Date→ localYYYY-MM-DD HH:mm:ss(strict ISO withT/Zis not parsed as a date by Excel), arrays joined with;element by element, and a leading'on formula-like strings (=,+,-,@) — numbers are exempt so negatives still sum. Callers pass raw values and prepend a\uFEFFBOM to theBlobso Excel reads UTF-8
- Parent-child: Props down, events up
- Cross-component: Vuex store or
EventBus(src/eventBus.ts) - Flash messages:
EventBus.$emit(text, { timeout })— displayed byFlashMessagecomponent
import.meta.glob()used insrc/dynamicImports.tsfor lazy-loading:I18nLocales— locale YAML filesCameraComponents— camera service Vue components
- Views also dynamically imported in
src/router/index.tsvia() => import('@/views/X.vue') src/plugins/vuetify.tsholds a privatelocalesmap of() => import('vuetify/lib/locale/*')thunks, keyed by Fluidd locale code, so the map doubles as the code translation (zh-CN→zh-Hans,zh-HK/zh-TW→zh-Hant,pt-BR→pt); a missing key (ta) leaves$vuetify.langonen. Locale files and codes are BCP 47 with regions, hyphenated (pt-BR,zh-TW) — whatnavigator.languagessends andIntlaccepts;pt_BRmakesIntlthrow. The Weblate component must use the matching hyphen language-code style.SupportedLocales(src/globals.ts) is hand-maintained with hardcoded native labels — a locale file without an entry there (sv,th,zh-TW) exists for Weblate but is not selectable.config/onLocaleChangedrives it vialoadVuetifyLocaleAsync, whichVue.sets the locale intolang.localesbefore assigninglang.current— thecurrentwrite is what re-renders. Theseimport()s do not actually code-split:vuetify/lib/presets/defaultstatically imports thelib/localebarrel, so every referenced locale lands in the eagervuetifychunk regardless ofcodeSplittinggroupsloadLocaleMessagesAsync(locale?)(src/plugins/i18n.ts) defaults togetStartingLocale()when called with no argument, guards oni18n.availableLocalesrather than a separate loaded-list, and on a failedI18nLocales[locale]()import logs via consola and keeps the currenti18n.localeinstead of throwing.getStartingLocalewalksnavigator.languages, matching each entry's full code first (pt-BR,zh-CN) then its bare language, before falling back toVUE_APP_I18N_LOCALEoren.config/onLocaleChangeholdsWaits.onLoadLanguagefor the duration, which drives the loading spinner on the languagev-selectinGeneralSettings
- Unit tests in any
src/**/__tests__/*.spec.ts— not justsrc/util/; e.g.src/workers/__tests__/parseGcode.spec.ts.tsconfig.vitest.jsonincludessrc/**/__tests__/*at any depth, plussrc/typings/*.d.tsso specs can reference theKlipper/Moonrakernamespaces, andtshelpers.d.ts, whichsrc/typingsbuilds on (TSHelpers.SnakeToCamelCase) environment: 'node'is the default, withpool: 'vmThreads'so each worker builds one environment instead of one per file. A spec needing a DOM opts in with a@vitest-environment jsdomdocblock — only the three Monarch tokenizer specs (monaco toucheswindow), the twohttp-endpoint-diagnosticsones, and thesafe-html,sanitize-htmlandmark-text-nodesones (DOMPurify andmarkTextNodesneed a DOM; without one DOMPurify sanitizes nothing) do. Building jsdom for all 21 files was ~40% of the run;environmentMatchGlobsis not an alternative, it was removed in Vitest 3- Under
vmThreads, jsdom'slocationcannot be redefined —Object.defineProperty,vi.stubGlobal('location', …)andvi.spyOn(location, 'protocol', 'get')all throwCannot redefine property. Set the page URL per file instead, with an@vitest-environment-options { "url": "https://…" }docblock; that is why the mixed-content case lives in its ownhttp-endpoint-diagnostics.mixed-content.spec.ts. jsdom's default URL is alreadyhttp://localhost:3000/, so http-protocol cases need no stub at all navigatoris a Node global (21+), so specs definingnavigator.languageson the instance (src/plugins/__tests__/i18n.spec.ts) work unchanged under thenodeenvironment- Monarch tokenizer tests co-located in
src/monaco/language/__tests__/— use sharedtokenize-helper.ts(registerLanguage,tokenizeLines,tokenBuilder) - Global test functions (
describe,it,expect,vi,afterEach, …) —globals: truein vitest config, typed via/// <reference types="vitest/globals" />inenv.d.ts; specs import no vitest symbols - Setup file:
tests/unit/setup.ts— includesCSS.escapeandwindow.matchMediapolyfills required by Monaco in jsdom. It runs for every spec, including thenode-environment ones, so thematchMediahalf stays behind atypeof window !== 'undefined'guard - Time manipulation utility:
timeTravel(date, callback)intests/unit/utils.ts - Parameterized tests:
it.each([...])pattern - Test store actions/mutations independently from UI
- Stub
fetchwithvi.stubGlobal('fetch', …), always restore withvi.unstubAllGlobals()inafterEach— seesrc/util/__tests__/http-endpoint-diagnostics.spec.tsandsrc/workers/__tests__/parseGcode.spec.ts(the latter stubs a realReadableStreambody) Float32Arrayvalues read back are inexact (0.2→0.20000000298…) — assert withtoBeCloseTo, or pick exactly-representable fixtures
-
Source must pass linting with zero warnings and zero type errors — run
pnpm run lintandpnpm run type-checkbefore committing -
Vue class-style components with
vue-property-decorator(@Component,@Prop,@VModel,Mixins()) -
ESLint enforced:
neostandard+pluginVue.configs['flat/vue2-recommended']+pluginRegexp+@vue/eslint-config-typescript -
.editorconfigrules: 2 spaces, LF line endings, UTF-8, trim trailing whitespace, max line 100 (code) -
camelCase for variables/methods, PascalCase for components
-
src/components/ui/components put a kebab-case class named after the component on their main element (AppInlineChart→app-inline-chart), with BEM__elementclasses for inner parts (app-inline-chart__label). Their scoped styles target these classes, not Vuetify's -
Any link opening a new tab (
target="_blank") must also setrel="noopener noreferrer"— applies to<a>,<v-btn>/<app-btn>with:href, and any HTML strings rendered viav-safe-html(e.g.src/util/html-helpers.ts) -
Render plain text as plain text:
<span class="text-pre-wrap">{{ value }}</span>, on one line sopre-wrapdoes not reveal template whitespace, and notv-text(lint rejects it on components).v-safe-htmlparses its input as HTML, so<none>disappears and an unterminated<minloses the rest of the string; keep it for strings that really carry markup. The form controls (AppNamedSwitch/Select/Slider/TextField),StatusLabelandCollapsableCard'ssubTitlerender plain text; markup in a label goes in theirlabelslot instead, which makes thelabelprop optional (OutputFanusesAppNamedSlider's for the RPM in<small>).AppInlineHelptooltips,AppDragOverlaymessages and theExtruderStatsstrings stay HTML, as their locale strings use<br>,<kbd>,<strong>and<span>. Store raw newlines, never<br>—server.infois sentrawso Moonraker's warnings arrive that way too — and put real newlines in locale strings rendered as plain text -
AppNamedSelect,AppNamedSliderandAppNamedTextFieldlay out their label and input throughAppNamedContainer(src/components/ui/), which owns the label column (cols="12" sm="5",text--disabled, the plain-text fallback).AppNamedSwitchdoes not use it — its label-left, switch-right row has no column split. Each control keepsinheritAttrs: falseand binds$attrsto its input; the container does not, so attributes set on it land on itsv-row. A control forwards itslabelslot only when given one —<template v-if="$scopedSlots.label" #label><slot name="label" /></template>— so the container's fallback renders from the prop otherwise; use$scopedSlots, as av-slotslot is not always in$slots -
src/util/html-helpers.ts(import-free) builds HTML from plain text:linkExternalUrlsescapes everything except the anchors it generates, which is whyAppWarningscan stay onv-safe-htmlwithout losing text.escapeHtmlescapes&,<,>,"and', so its output is safe in text and quoted attribute values -
v-safe-html(src/directives/safe-html.ts) sanitizes to a DOM fragment withsanitizeHtml(src/util/sanitize-html.ts, which owns the DOMPurify config and its hooks: an<a>(HTML or SVG, matched bylocalName, readinghreforxlink:href) whosehrefnames its ownhttp(s)host is forced totarget="_blank"— decided by resolving it withnew URLagainst a dummyhttp://relative.invalid/base, not a regex, so backslash, tab and slash-less forms (\\x.com,https:x.com) the browser treats as external are caught — an author'starget="_blank"is kept on any<a>, and every_blanklink getsnoopenerandnoreferreradded to itsrel, so macro HTML can't skip them. Links withouthref(ConsoleItem's command links), relative, hash,mailto:andtel:links keep the same tab) and inserts it withreplaceChildren, neverinnerHTML: the nodes DOMPurify checked are the ones rendered, with no serialize and re-parse for mutation XSS to exploit. Its value is an HTML string or{ html, search? }(SafeHtmlValue); asearchterm is highlighted bymarkTextNodes(src/util/mark-text-nodes.ts): the raw term, case-insensitive, wrapped in<mark>in text nodes only, skipping<style>. An object value is a new object on every render, so the directive compareshtmlandsearchfield by field and skips the update only when both match -
ConsoleItemrenders aresponseas HTML only whenhasRenderableHtmlfinds markup DOMPurify keeps, so macros can output<img>and<video>; anything else is escaped withescapeHtmland gets command links if it is aresponse. The check sanitizes the message and looks for a surviving element, behind a/<[a-z]/ipre-check so most lines skip DOMPurify. So<none>,<x>,<temp>orUnknown algorithm <foo>keep the message plain — this matters because Klipper sends a wholehelplisting as one entry, so one macro description such asM163 [P<factor>]used to cost every line its links. Limits: a placeholder named like a real tag (<b>,<i>,<p>,<s>,<u>) renders as HTML, and in a message with real markup any other<word>is still dropped. Stored messages stay raw, so console filters are unaffected. Search passes{ html, search }tov-safe-html, so tags, attributes and entities can't be hit, plain-text and HTML messages match alike, and a match can't span two elements (such as the$prefix and the command link).itemClickresolves a click on a<mark>to its parent anchor -
Use
consolafor logging, notconsole.log(configured insrc/setupConsola.ts— warn in prod, verbose in dev) -
Type imports:
import type { ... }for types only (verbatimModuleSyntax: true) -
satisfieskeyword for store module type checking -
decimalRound(value, places)(src/util/decimal-round.ts) is the shared rounding helper — use it instead of ad-hocMath.round(value * 100) / 100 -
Stable
v-forkeys — key by identity, never by index (:key="`component::${component.name}`"). Separators belong inside the loop with their own key (:key="`component:divider:${component.name}`") rather than a trailingv-if="i < items.length - 1"sibling -
No double-cast type assertions (
as unknown as T) — use a proper TypeScript type guard instead (in,typeof,instanceof, or a custom type predicate):// Bad const x = value as unknown as { test: () => void } x?.test?.() // Good if (value && 'test' in value && typeof value.test === 'function') { value.test() }
- Conventional commits required:
feat,fix,docs,style,refactor,perf,test,build,ci,chore,revert,types,i18n - Commit subject max 50 characters — hard-enforced by
.husky/commit-msghook - Signed-off-by line required on all commits (use
git commit -s):Signed-off-by: Your Name <your@email> - PR titles must follow conventional commits — CI-enforced via
amannn/action-semantic-pull-request(scope optional) - PR branches must be off a branch other than
developormaster - Clean develop preferred: squash and rebase feature branches prior to merge
- CHANGELOG visibility: only
feat,fix,perf,refactorappear inCHANGELOG.md(configured in.versionrc.json) - CI setup:
.github/actions/setup-pnpm-nodeis a composite action wrappingpnpm/setup(install: false,cache: true), which installs both pnpm and Node fromdevEngines— it replacedpnpm/action-setup+actions/setup-node.ci.yml'sbundle-baselinejob uses it too, which is why itssparse-checkoutlists.github/actionsalongsidetools: cone mode checks out root files (sopackage.jsonandpnpm-lock.yamlare there) but no other directory - CI pipeline order:
pnpm i --frozen-lockfile→lint --no-fix→type-check→test:unit→circular-check→build. The three checks after lint (type-check,test:unit,circular-check) each carryif: ${{ !cancelled() }}, so a lint failure no longer hides them — one run reports all four.buildand the artifact upload deliberately do not, so they still skip once anything above has failed - Reusable workflows: the build and both publish paths live in
_build.yml,_publish-docker.ymland_publish-web.yml(workflow_call), called byci.yml(PRs +develop/masterpushes) andrelease.yml(v*tags, whichci.ymlno longer triggers on). A calling job'spermissions:is a ceiling on the called workflow's token, so every calling job needs its own explicit block — a top-levelpermissions: {}alone starves it. Secret values can't crossworkflow_callviawith:;_publish-web.ymltakes them throughon.workflow_call.secrets - PR bundle-size report:
tools/bundle-size.mjs(zero-dependency) emits a gzip-size manifest per build and diffs the PR against its merge-base, posted as a sticky comment bypr-comment.yml. That second workflow exists because the report has to build PR code, so it can't hold a write token — it'sworkflow_run-triggered, reads only an artifact, and takes the PR number frompr-number.txtsinceworkflow_run.pull_requests[]is empty for fork PRs.workflows: ['CI']must matchci.yml'sname:exactly, and aworkflow_runtrigger only fires once the file exists on the default branch - Bundle-size baselines are cached by
bundle-size-<hash of tools/bundle-size.mjs>-<sha>— the tool's own hash is in the key because the manifest format is not versioned, so without it a format change silently poisons every cached baseline (they're keyed by a base SHA that never changes) andcomparepublishes a table ofNaN Bon a green check.comparealso rejects non-finite entries for the same reason. The compare step setsshell: bashforpipefail; the implicit default isbash -ewithout it, so a failure would be masked bytee
- Vue 2.7 limitations: no Composition API in production builds
- WebSocket reconnection handled automatically by
socketClient.ts - File uploads use FormData with progress tracking in store
- Dynamic imports for code splitting (see
vue-echarts-chunk.ts,src/dynamicImports.ts) - SCSS deprecation warnings silenced:
import,global-builtin,slash-div,if-function @/scss/variablesauto-injected into all SCSS/Sass files via Vite config- Strict Vuex mode enabled only in dev (
strict: import.meta.env.DEV) - SVG files auto-optimized on commit — pre-commit hook runs SVGO on staged
.svg,.vue, andsrc/globals.tsfiles VUE_env prefix required — only env vars prefixedVUE_are exposed to app code viaimport.meta.env(ViteenvPrefix)import.meta.env.VERSIONandimport.meta.env.HASH(short git hash) are injected at build timeserver/config.jsonis the runtime config source (deployed asdist/config.json) — contains theme presets, endpoints, hosted flagnpmandnpxrefuse to run inside this repo —devEngines.packageManagernamespnpmwithonFail: download, which npm reads as a hardEBADDEVENGINESfailure (no flag overrides it,engine-strictincluded). Usepnpm dlxin place ofnpx, and install pnpm itself from outside the clone- Translations managed via Weblate — do not directly edit non-English locale files in
src/locales/
- VSCode Dev Container (
.devcontainer/) bundles adocker-klipper-simulavrcontainer — real Klipper/Moonraker simulation on port 7125, Fluidd on port 8080 - Base image is
mcr.microsoft.com/devcontainers/javascript-node:26, Debian-based. It must stay glibc —typescript-native-bridgeships a Goc-sharedNAPI bridge with glibc-only native packages (no-muslbuild), so on Alpine it fails to load and segfaults even withgcompat - The image ships Node.js, pnpm, git, zsh, Oh My Zsh and the
nodeuser (uid 1000) thatdevcontainer.json'sremoteUsernames, so there is no Dockerfile —docker-compose.ymluses the image directly. The image defaults toroot, andremoteUseronly covers VS Code and lifecycle commands, so the service also setsuser: nodeandworking_dir; a rootpnpm ifromdocker compose execwould leave filesnodecannot write. A freshnode_modulesnamed volume is root-owned, sopostCreateCommandstarts withsudo chown node:node node_modules(idempotent, not recursive). pnpm's store lands inside that volume (node_modules/.pnpm-store), so hard links work and it survives rebuilds - The image's pnpm switches to
devEngines.packageManagerinside the project. A barenodeis the image's own version, which can lag the pin; anything run through pnpm ornode_modules/.binusesdevEngines.runtime postCreateCommandrunspnpm i --frozen-lockfileautomatically (which in turn runsprepare)
Dockerfilebuilds onnginx:alpine-slim(fluidd) /nginxinc/nginx-unprivileged:alpine-slim(fluidd-unprivileged), set via aBASE_IMAGEbuild arg per matrix entry in.github/workflows/_publish-docker.yml- The listen port is a
PORTbuild arg re-exported asENV PORT(default80), substituted intoserver/nginx/default.conf.templateby nginx's own envsubst entrypoint at container start — not baked in at build time. CI used tosedthe template's port inbuild.yml(nowci.yml); that step is gone now thatPORTflows through as a build arg, and the port can also be overridden atdocker runtime HEALTHCHECK(wget -q -O /dev/null "http://127.0.0.1:${PORT}/healthz", every 10s) — the base image must keepwget;/healthzis a plain200 okindefault.conf.template, excluded from the access log
- Zensical (Material for MkDocs successor) — static site generator in
docs/ - Config:
docs/zensical.toml— nav, theme, extensions, plugins (glightbox,llmstxt,social,minify,redirects), social links [project.markdown_extensions]REPLACES Zensical'sDEFAULT_MARKDOWN_EXTENSIONS, it does not merge with them (config.get("markdown_extensions", DEFAULTS)inzensical/config.py). Anything omitted from that table is off, includingtoc.permalink— dropping an entry silently removes heading anchors sitewide. Keep it in sync withzensical/bootstrap/zensical.tomlin the installed packagepymdownx.tabbedcarriescombine_header_slug+slugify, so a content tab is linkable as#<enclosing-heading>-<tab-label>(e.g.#thumbnails-orcaslicer). Replacing a heading with a tab changes its anchor — preserve the old one with an anchor redirect in[project.plugins.redirects.redirect_maps]("page.md#old" = "page.md#new", Zensical 0.0.61+), which lands insite/redirect.jsonllmstxtemitsllms.txt/llms-full.txtplus a per-pageindex.md(backing thecontent.action.copybutton) — it only includes pages matched by itssections, so a new top-level page must be added there toosocialgenerates Open Graph cards; our palette sets noprimary, socards_layout_optionspassesbackground_colorandcolor(the square logo needs the dark background). It fetches fonts from Google Fonts at build timeprivacyself-hosts external assets undersite/assets/external/, restricted by anassets_includeallow-list (Google Fonts, glightbox). Don't switch it to exclusions: the theme bundle references unused lazy libraries (pyodide, ace, mermaid, plyr, vimeo), and plyr's pull in Google ad SDKs that fail the--strictbuild. Unlisted URLs, like the Weblate badge, stay externaloptimizeis deliberately not enabled — it shells out topngquant, which would become a requirement for every localzensical serve, and TOML config has no!ENVto enable it in CI only- Internal links are relative
.mdpaths (printing.md#thumbnails,../configuration.md), never site-absolute — Zensical'sinvalid_links/invalid_link_anchorsvalidation cannot resolve/features/printing, so absolute links are silently unchecked. Image paths stay site-absolute (/assets/images/…) - Content:
docs/docs/— Markdown files with YAML frontmatter - Overrides:
docs/overrides/— custom Jinja2 templates (header, htmltitle) - Custom CSS:
docs/docs/stylesheets/extra.css— Fluidd brand colors - Glossary:
docs/includes/glossary.md— abbreviation tooltips auto-appended to all pages - Lint:
markdownlint --config docs/.markdownlint.json docs/docs/— in CI it runs aspnpm dlx markdownlint-cli@…, becausenpxcannot run inside this repo (EBADDEVENGINES), which is whydocs.ymlsets up pnpm at all - Install:
cd docs && python3 -m venv .venv && source .venv/bin/activate && pip install -r requirements.txt - Build:
cd docs && zensical build --clean --strict(CI uses--strict; warnings, including broken links/anchors, fail the build) - Serve:
cd docs && zensical serveorpnpm run serve:docs(localhost:8000) - Deploy: GitHub Actions (
.github/workflows/docs.yml) — builds and lints (codespell+markdownlint) on PRs and on pushes tomasteranddevelop; uploads the Pages artifact viaactions/upload-pages-artifacton both branches, but onlymasterruns thedeployjob (actions/deploy-pages)
docs/
├── docs/ # Markdown content
│ ├── index.md # Homepage
│ ├── getting-started.md # Installation (KIAUH, Docker, Manual, fluidd.xyz, FluiddPI)
│ ├── configuration.md # Fluidd Config, Klipper, Moonraker, Multiple Printers
│ ├── customize.md # Layout, themes, hiding components
│ ├── features/
│ │ ├── index.md # Features overview (section landing page)
│ │ ├── authorization.md
│ │ ├── cameras.md
│ │ ├── console.md
│ │ ├── diagnostics.md
│ │ ├── file-editor.md # Monaco editor features, syntax, CodeLens, folding
│ │ ├── file-manager.md # File browser, upload, search, previews, drag-and-drop
│ │ ├── job-queue.md # Sequential printing queue
│ │ ├── keyboard-shortcuts.md # Global, editor, console keyboard shortcuts
│ │ ├── localization.md
│ │ ├── macros.md
│ │ ├── multi-material.md # Multiple extruders + Spoolman
│ │ ├── multiple-printers.md
│ │ ├── printing.md # G-code viewer, thumbnails, bed mesh, print history
│ │ ├── slicer-uploads.md
│ │ ├── system-and-notifications.md # System info + notifications
│ │ ├── thermals.md # Chart, presets, sensors
│ │ ├── third-party-integrations.md # Kalico, Happy Hare, AFC, Beacon, Obico, OctoEverywhere, etc.
│ │ ├── timelapse.md
│ │ └── updates.md
│ ├── development.md # Dev container, local dev, localization
│ ├── faq.md # Organized by topic (Setup, Cameras, System, Printing)
│ └── sponsors.md
├── includes/
│ └── glossary.md # Abbreviation definitions (auto-appended)
├── overrides/ # Jinja2 template overrides
├── zensical.toml # Site configuration
└── .markdownlint.json # Lint rules (MD013/MD025/MD046 disabled, MD033 allows `div` for card grids)
- Frontmatter:
title(required),icon(top-level pages only, Lucide icons) - Images:
/assets/images/path, stored indocs/docs/assets/images/ - Code blocks must always have a language tag:
inifor Klipper/Moonraker config,bashfor shell commands,jsonfor JSON,textwhen no language applies - Zensical uses Python-Markdown which requires 4-space indentation per nesting level for all block-level elements nested in lists (sub-lists, paragraphs, code blocks, blockquotes) — no tabs
- Tables must use aligned pipe style (columns padded to equal width)
- Links: use
{.md-button}attribute for standalone action links - Keys: use
++key++syntax (pymdownx.keys extension) instead of<kbd> - Terminology: G-code (not gcode/Gcode), Wi-Fi (not WiFi), GitHub (not Github), Node.js (not NodeJS), SD card (not SDCard), em dash (—) not hyphen (-) for parenthetical dashes
- Klipper macro names: format as inline code (e.g.,
PAUSE,SET_PAUSE_AT_LAYER,_CLIENT_VARIABLE) - Klipper/Moonraker section names and config variable names: format as inline code (e.g.,
[virtual_sdcard],enable_object_processing) — exception: leave unformatted when used as markdown headings - Glossary terms (AFC, API, CNC, CORS, JWT, MCU, MMU, MPC, PID, etc.) get automatic tooltips via
docs/includes/glossary.md - When introducing acronyms in docs, check if they exist in the glossary — if not, assess whether they should be added (domain-specific or non-obvious acronyms: yes; universally known ones like USB, HTTP, CPU: no)
- Before committing docs changes, always run:
markdownlint --config docs/.markdownlint.json docs/docs/— must be cleancodespell docs/docs/— must be clean
- Be extremely concise in responses
- Sacrifice grammar for brevity
- Focus on essential info only